UNPKG

hdr-canvas

Version:
2,107 lines (1,740 loc) 153 kB
// A is m x n. B is n x p. product is m x p. function multiplyMatrices (A, B) { let m = A.length; if (!Array.isArray(A[0])) { // A is vector, convert to [[a, b, c, ...]] A = [A]; } if (!Array.isArray(B[0])) { // B is vector, convert to [[a], [b], [c], ...]] B = B.map(x => [x]); } let p = B[0].length; let B_cols = B[0].map((_, i) => B.map(x => x[i])); // transpose B let product = A.map(row => B_cols.map(col => { let ret = 0; if (!Array.isArray(row)) { for (let c of col) { ret += row * c; } return ret; } for (let i = 0; i < row.length; i++) { ret += row[i] * (col[i] || 0); } return ret; })); if (m === 1) { product = product[0]; // Avoid [[a, b, c, ...]] } if (p === 1) { return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]] } return product; } /** * Various utility functions */ /** * Check if a value is a string (including a String object) * @param {*} str - Value to check * @returns {boolean} */ function isString (str) { return type(str) === "string"; } /** * Determine the internal JavaScript [[Class]] of an object. * @param {*} o - Value to check * @returns {string} */ function type (o) { let str = Object.prototype.toString.call(o); return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase(); } function serializeNumber (n, {precision, unit }) { if (isNone(n)) { return "none"; } return toPrecision(n, precision) + (unit ?? ""); } /** * Check if a value corresponds to a none argument * @param {*} n - Value to check * @returns {boolean} */ function isNone (n) { return Number.isNaN(n) || (n instanceof Number && n?.none); } /** * Replace none values with 0 */ function skipNone (n) { return isNone(n) ? 0 : n; } /** * Round a number to a certain number of significant digits * @param {number} n - The number to round * @param {number} precision - Number of significant digits */ function toPrecision (n, precision) { if (n === 0) { return 0; } let integer = ~~n; let digits = 0; if (integer && precision) { digits = ~~Math.log10(Math.abs(integer)) + 1; } const multiplier = 10.0 ** (precision - digits); return Math.floor(n * multiplier + 0.5) / multiplier; } const angleFactor = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360, }; /** * Parse a CSS function, regardless of its name and arguments * @param String str String to parse * @return {{name, args, rawArgs}} */ function parseFunction (str) { if (!str) { return; } str = str.trim(); const isFunctionRegex = /^([a-z]+)\((.+?)\)$/i; const isNumberRegex = /^-?[\d.]+$/; const unitValueRegex = /%|deg|g?rad|turn$/; const singleArgument = /\/?\s*(none|[-\w.]+(?:%|deg|g?rad|turn)?)/g; let parts = str.match(isFunctionRegex); if (parts) { // It is a function, parse args let args = []; parts[2].replace(singleArgument, ($0, rawArg) => { let match = rawArg.match(unitValueRegex); let arg = rawArg; if (match) { let unit = match[0]; // Drop unit from value let unitlessArg = arg.slice(0, -unit.length); if (unit === "%") { // Convert percentages to 0-1 numbers arg = new Number(unitlessArg / 100); arg.type = "<percentage>"; } else { // Multiply angle by appropriate factor for its unit arg = new Number(unitlessArg * angleFactor[unit]); arg.type = "<angle>"; arg.unit = unit; } } else if (isNumberRegex.test(arg)) { // Convert numerical args to numbers arg = new Number(arg); arg.type = "<number>"; } else if (arg === "none") { arg = new Number(NaN); arg.none = true; } if ($0.startsWith("/")) { // It's alpha arg = arg instanceof Number ? arg : new Number(arg); arg.alpha = true; } if (typeof arg === "object" && arg instanceof Number) { arg.raw = rawArg; } args.push(arg); }); return { name: parts[1].toLowerCase(), rawName: parts[1], rawArgs: parts[2], // An argument could be (as of css-color-4): // a number, percentage, degrees (hue), ident (in color()) args, }; } } function last (arr) { return arr[arr.length - 1]; } function interpolate (start, end, p) { if (isNaN(start)) { return end; } if (isNaN(end)) { return start; } return start + (end - start) * p; } function interpolateInv (start, end, value) { return (value - start) / (end - start); } function mapRange (from, to, value) { return interpolate(to[0], to[1], interpolateInv(from[0], from[1], value)); } function parseCoordGrammar (coordGrammars) { return coordGrammars.map(coordGrammar => { return coordGrammar.split("|").map(type => { type = type.trim(); let range = type.match(/^(<[a-z]+>)\[(-?[.\d]+),\s*(-?[.\d]+)\]?$/); if (range) { let ret = new String(range[1]); ret.range = [+range[2], +range[3]]; return ret; } return type; }); }); } /** * Clamp value between the minimum and maximum * @param {number} min minimum value to return * @param {number} val the value to return if it is between min and max * @param {number} max maximum value to return * @returns number */ function clamp (min, val, max) { return Math.max(Math.min(max, val), min); } /** * Copy sign of one value to another. * @param {number} - to number to copy sign to * @param {number} - from number to copy sign from * @returns number */ function copySign (to, from) { return Math.sign(to) === Math.sign(from) ? to : -to; } /** * Perform pow on a signed number and copy sign to result * @param {number} - base the base number * @param {number} - exp the exponent * @returns number */ function spow (base, exp) { return copySign(Math.abs(base) ** exp, base); } /** * Perform a divide, but return zero if the numerator is zero * @param {number} n - the numerator * @param {number} d - the denominator * @returns number */ function zdiv (n, d) { return (d === 0) ? 0 : n / d; } /** * Perform a bisect on a sorted list and locate the insertion point for * a value in arr to maintain sorted order. * @param {number[]} arr - array of sorted numbers * @param {number} value - value to find insertion point for * @param {number} lo - used to specify a the low end of a subset of the list * @param {number} hi - used to specify a the high end of a subset of the list * @returns number */ function bisectLeft (arr, value, lo = 0, hi = arr.length) { while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] < value) { lo = mid + 1; } else { hi = mid; } } return lo; } var util = /*#__PURE__*/Object.freeze({ __proto__: null, bisectLeft: bisectLeft, clamp: clamp, copySign: copySign, interpolate: interpolate, interpolateInv: interpolateInv, isNone: isNone, isString: isString, last: last, mapRange: mapRange, multiplyMatrices: multiplyMatrices, parseCoordGrammar: parseCoordGrammar, parseFunction: parseFunction, serializeNumber: serializeNumber, skipNone: skipNone, spow: spow, toPrecision: toPrecision, type: type, zdiv: zdiv }); /** * A class for adding deep extensibility to any piece of JS code */ class Hooks { add (name, callback, first) { if (typeof arguments[0] != "string") { // Multiple hooks for (var name in arguments[0]) { this.add(name, arguments[0][name], arguments[1]); } return; } (Array.isArray(name) ? name : [name]).forEach(function (name) { this[name] = this[name] || []; if (callback) { this[name][first ? "unshift" : "push"](callback); } }, this); } run (name, env) { this[name] = this[name] || []; this[name].forEach(function (callback) { callback.call(env && env.context ? env.context : env, env); }); } } /** * The instance of {@link Hooks} used throughout Color.js */ const hooks = new Hooks(); // Global defaults one may want to configure var defaults = { gamut_mapping: "css", precision: 5, deltaE: "76", // Default deltaE method verbose: globalThis?.process?.env?.NODE_ENV?.toLowerCase() !== "test", warn: function warn (msg) { if (this.verbose) { globalThis?.console?.warn?.(msg); } }, }; const WHITES = { // for compatibility, the four-digit chromaticity-derived ones everyone else uses D50: [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585], D65: [0.3127 / 0.3290, 1.00000, (1.0 - 0.3127 - 0.3290) / 0.3290], }; function getWhite (name) { if (Array.isArray(name)) { return name; } return WHITES[name]; } // Adapt XYZ from white point W1 to W2 function adapt$2 (W1, W2, XYZ, options = {}) { W1 = getWhite(W1); W2 = getWhite(W2); if (!W1 || !W2) { throw new TypeError(`Missing white point to convert ${!W1 ? "from" : ""}${!W1 && !W2 ? "/" : ""}${!W2 ? "to" : ""}`); } if (W1 === W2) { // Same whitepoints, no conversion needed return XYZ; } let env = {W1, W2, XYZ, options}; hooks.run("chromatic-adaptation-start", env); if (!env.M) { if (env.W1 === WHITES.D65 && env.W2 === WHITES.D50) { env.M = [ [ 1.0479297925449969, 0.022946870601609652, -0.05019226628920524 ], [ 0.02962780877005599, 0.9904344267538799, -0.017073799063418826 ], [ -0.009243040646204504, 0.015055191490298152, 0.7518742814281371 ], ]; } else if (env.W1 === WHITES.D50 && env.W2 === WHITES.D65) { env.M = [ [ 0.955473421488075, -0.02309845494876471, 0.06325924320057072 ], [ -0.0283697093338637, 1.0099953980813041, 0.021041441191917323 ], [ 0.012314014864481998, -0.020507649298898964, 1.330365926242124 ], ]; } } hooks.run("chromatic-adaptation-end", env); if (env.M) { return multiplyMatrices(env.M, env.XYZ); } else { throw new TypeError("Only Bradford CAT with white points D50 and D65 supported for now."); } } const noneTypes = new Set(["<number>", "<percentage>", "<angle>"]); /** * Validates the coordinates of a color against a format's coord grammar and * maps the coordinates to the range or refRange of the coordinates. * @param {ColorSpace} space - Colorspace the coords are in * @param {object} format - the format object to validate against * @param {string} name - the name of the color function. e.g. "oklab" or "color" * @returns {object[]} - an array of type metadata for each coordinate */ function coerceCoords (space, format, name, coords) { let types = Object.entries(space.coords).map(([id, coordMeta], i) => { let coordGrammar = format.coordGrammar[i]; let arg = coords[i]; let providedType = arg?.type; // Find grammar alternative that matches the provided type // Non-strict equals is intentional because we are comparing w/ string objects let type; if (arg.none) { type = coordGrammar.find(c => noneTypes.has(c)); } else { type = coordGrammar.find(c => c == providedType); } // Check that each coord conforms to its grammar if (!type) { // Type does not exist in the grammar, throw let coordName = coordMeta.name || id; throw new TypeError(`${providedType ?? arg.raw} not allowed for ${coordName} in ${name}()`); } let fromRange = type.range; if (providedType === "<percentage>") { fromRange ||= [0, 1]; } let toRange = coordMeta.range || coordMeta.refRange; if (fromRange && toRange) { coords[i] = mapRange(fromRange, toRange, coords[i]); } return type; }); return types; } /** * Convert a CSS Color string to a color object * @param {string} str * @param {object} [options] * @param {object} [options.meta] - Object for additional information about the parsing * @returns {Color} */ function parse (str, {meta} = {}) { let env = {"str": String(str)?.trim()}; hooks.run("parse-start", env); if (env.color) { return env.color; } env.parsed = parseFunction(env.str); if (env.parsed) { // Is a functional syntax let name = env.parsed.name; if (name === "color") { // color() function let id = env.parsed.args.shift(); // Check against both <dashed-ident> and <ident> versions let alternateId = id.startsWith("--") ? id.substring(2) : `--${id}`; let ids = [id, alternateId]; let alpha = env.parsed.rawArgs.indexOf("/") > 0 ? env.parsed.args.pop() : 1; for (let space of ColorSpace.all) { let colorSpec = space.getFormat("color"); if (colorSpec) { if (ids.includes(colorSpec.id) || colorSpec.ids?.filter((specId) => ids.includes(specId)).length) { // From https://drafts.csswg.org/css-color-4/#color-function // If more <number>s or <percentage>s are provided than parameters that the colorspace takes, the excess <number>s at the end are ignored. // If less <number>s or <percentage>s are provided than parameters that the colorspace takes, the missing parameters default to 0. (This is particularly convenient for multichannel printers where the additional inks are spot colors or varnishes that most colors on the page won’t use.) const coords = Object.keys(space.coords).map((_, i) => env.parsed.args[i] || 0); let types; if (colorSpec.coordGrammar) { types = coerceCoords(space, colorSpec, "color", coords); } if (meta) { Object.assign(meta, {formatId: "color", types}); } if (colorSpec.id.startsWith("--") && !id.startsWith("--")) { defaults.warn(`${space.name} is a non-standard space and not currently supported in the CSS spec. ` + `Use prefixed color(${colorSpec.id}) instead of color(${id}).`); } if (id.startsWith("--") && !colorSpec.id.startsWith("--")) { defaults.warn(`${space.name} is a standard space and supported in the CSS spec. ` + `Use color(${colorSpec.id}) instead of prefixed color(${id}).`); } return {spaceId: space.id, coords, alpha}; } } } // Not found let didYouMean = ""; let registryId = id in ColorSpace.registry ? id : alternateId; if (registryId in ColorSpace.registry) { // Used color space id instead of color() id, these are often different let cssId = ColorSpace.registry[registryId].formats?.color?.id; if (cssId) { didYouMean = `Did you mean color(${cssId})?`; } } throw new TypeError(`Cannot parse color(${id}). ` + (didYouMean || "Missing a plugin?")); } else { for (let space of ColorSpace.all) { // color space specific function let format = space.getFormat(name); if (format && format.type === "function") { let alpha = 1; if (format.lastAlpha || last(env.parsed.args).alpha) { alpha = env.parsed.args.pop(); } let coords = env.parsed.args; let types; if (format.coordGrammar) { types = coerceCoords(space, format, name, coords); } if (meta) { Object.assign(meta, {formatId: format.name, types}); } return { spaceId: space.id, coords, alpha, }; } } } } else { // Custom, colorspace-specific format for (let space of ColorSpace.all) { for (let formatId in space.formats) { let format = space.formats[formatId]; if (format.type !== "custom") { continue; } if (format.test && !format.test(env.str)) { continue; } let color = format.parse(env.str); if (color) { color.alpha ??= 1; if (meta) { meta.formatId = formatId; } return color; } } } } // If we're here, we couldn't parse throw new TypeError(`Could not parse ${str} as a color. Missing a plugin?`); } /** * Resolves a color reference (object or string) to a plain color object * @param {Color | {space, coords, alpha} | string | Array<Color | {space, coords, alpha} | string> } color * @returns {{space, coords, alpha} | Array<{space, coords, alpha}}> */ function getColor (color) { if (Array.isArray(color)) { return color.map(getColor); } if (!color) { throw new TypeError("Empty color reference"); } if (isString(color)) { color = parse(color); } // Object fixup let space = color.space || color.spaceId; if (!(space instanceof ColorSpace)) { // Convert string id to color space object color.space = ColorSpace.get(space); } if (color.alpha === undefined) { color.alpha = 1; } return color; } const ε$7 = .000075; /** * Class to represent a color space */ class ColorSpace { constructor (options) { this.id = options.id; this.name = options.name; this.base = options.base ? ColorSpace.get(options.base) : null; this.aliases = options.aliases; if (this.base) { this.fromBase = options.fromBase; this.toBase = options.toBase; } // Coordinate metadata let coords = options.coords ?? this.base.coords; for (let name in coords) { if (!("name" in coords[name])) { coords[name].name = name; } } this.coords = coords; // White point let white = options.white ?? this.base.white ?? "D65"; this.white = getWhite(white); // Sort out formats this.formats = options.formats ?? {}; for (let name in this.formats) { let format = this.formats[name]; format.type ||= "function"; format.name ||= name; } if (!this.formats.color?.id) { this.formats.color = { ...this.formats.color ?? {}, id: options.cssId || this.id, }; } // Gamut space if (options.gamutSpace) { // Gamut space explicitly specified this.gamutSpace = options.gamutSpace === "self" ? this : ColorSpace.get(options.gamutSpace); } else { // No gamut space specified, calculate a sensible default if (this.isPolar) { // Do not check gamut through polar coordinates this.gamutSpace = this.base; } else { this.gamutSpace = this; } } // Optimize inGamut for unbounded spaces if (this.gamutSpace.isUnbounded) { this.inGamut = (coords, options) => { return true; }; } // Other stuff this.referred = options.referred; // Compute ancestors and store them, since they will never change Object.defineProperty(this, "path", { value: getPath(this).reverse(), writable: false, enumerable: true, configurable: true, }); hooks.run("colorspace-init-end", this); } inGamut (coords, {epsilon = ε$7} = {}) { if (!this.equals(this.gamutSpace)) { coords = this.to(this.gamutSpace, coords); return this.gamutSpace.inGamut(coords, {epsilon}); } let coordMeta = Object.values(this.coords); return coords.every((c, i) => { let meta = coordMeta[i]; if (meta.type !== "angle" && meta.range) { if (Number.isNaN(c)) { // NaN is always in gamut return true; } let [min, max] = meta.range; return (min === undefined || c >= min - epsilon) && (max === undefined || c <= max + epsilon); } return true; }); } get isUnbounded () { return Object.values(this.coords).every(coord => !("range" in coord)); } get cssId () { return this.formats?.color?.id || this.id; } get isPolar () { for (let id in this.coords) { if (this.coords[id].type === "angle") { return true; } } return false; } getFormat (format) { if (typeof format === "object") { format = processFormat(format, this); return format; } let ret; if (format === "default") { // Get first format ret = Object.values(this.formats)[0]; } else { ret = this.formats[format]; } if (ret) { ret = processFormat(ret, this); return ret; } return null; } /** * Check if this color space is the same as another color space reference. * Allows proxying color space objects and comparing color spaces with ids. * @param {string | ColorSpace} space ColorSpace object or id to compare to * @returns {boolean} */ equals (space) { if (!space) { return false; } return this === space || this.id === space || this.id === space.id; } to (space, coords) { if (arguments.length === 1) { const color = getColor(space); [space, coords] = [color.space, color.coords]; } space = ColorSpace.get(space); if (this.equals(space)) { // Same space, no change needed return coords; } // Convert NaN to 0, which seems to be valid in every coordinate of every color space coords = coords.map(c => Number.isNaN(c) ? 0 : c); // Find connection space = lowest common ancestor in the base tree let myPath = this.path; let otherPath = space.path; let connectionSpace, connectionSpaceIndex; for (let i = 0; i < myPath.length; i++) { if (myPath[i].equals(otherPath[i])) { connectionSpace = myPath[i]; connectionSpaceIndex = i; } else { break; } } if (!connectionSpace) { // This should never happen throw new Error(`Cannot convert between color spaces ${this} and ${space}: no connection space was found`); } // Go up from current space to connection space for (let i = myPath.length - 1; i > connectionSpaceIndex; i--) { coords = myPath[i].toBase(coords); } // Go down from connection space to target space for (let i = connectionSpaceIndex + 1; i < otherPath.length; i++) { coords = otherPath[i].fromBase(coords); } return coords; } from (space, coords) { if (arguments.length === 1) { const color = getColor(space); [space, coords] = [color.space, color.coords]; } space = ColorSpace.get(space); return space.to(this, coords); } toString () { return `${this.name} (${this.id})`; } getMinCoords () { let ret = []; for (let id in this.coords) { let meta = this.coords[id]; let range = meta.range || meta.refRange; ret.push(range?.min ?? 0); } return ret; } static registry = {}; // Returns array of unique color spaces static get all () { return [...new Set(Object.values(ColorSpace.registry))]; } static register (id, space) { if (arguments.length === 1) { space = arguments[0]; id = space.id; } space = this.get(space); if (this.registry[id] && this.registry[id] !== space) { throw new Error(`Duplicate color space registration: '${id}'`); } this.registry[id] = space; // Register aliases when called without an explicit ID. if (arguments.length === 1 && space.aliases) { for (let alias of space.aliases) { this.register(alias, space); } } return space; } /** * Lookup ColorSpace object by name * @param {ColorSpace | string} name */ static get (space, ...alternatives) { if (!space || space instanceof ColorSpace) { return space; } let argType = type(space); if (argType === "string") { // It's a color space id let ret = ColorSpace.registry[space.toLowerCase()]; if (!ret) { throw new TypeError(`No color space found with id = "${space}"`); } return ret; } if (alternatives.length) { return ColorSpace.get(...alternatives); } throw new TypeError(`${space} is not a valid color space`); } /** * Get metadata about a coordinate of a color space * * @static * @param {Array | string} ref * @param {ColorSpace | string} [workingSpace] * @return {Object} */ static resolveCoord (ref, workingSpace) { let coordType = type(ref); let space, coord; if (coordType === "string") { if (ref.includes(".")) { // Absolute coordinate [space, coord] = ref.split("."); } else { // Relative coordinate [space, coord] = [, ref]; } } else if (Array.isArray(ref)) { [space, coord] = ref; } else { // Object space = ref.space; coord = ref.coordId; } space = ColorSpace.get(space); if (!space) { space = workingSpace; } if (!space) { throw new TypeError(`Cannot resolve coordinate reference ${ref}: No color space specified and relative references are not allowed here`); } coordType = type(coord); if (coordType === "number" || coordType === "string" && coord >= 0) { // Resolve numerical coord let meta = Object.entries(space.coords)[coord]; if (meta) { return {space, id: meta[0], index: coord, ...meta[1]}; } } space = ColorSpace.get(space); let normalizedCoord = coord.toLowerCase(); let i = 0; for (let id in space.coords) { let meta = space.coords[id]; if (id.toLowerCase() === normalizedCoord || meta.name?.toLowerCase() === normalizedCoord) { return {space, id, index: i, ...meta}; } i++; } throw new TypeError(`No "${coord}" coordinate found in ${space.name}. Its coordinates are: ${Object.keys(space.coords).join(", ")}`); } static DEFAULT_FORMAT = { type: "functions", name: "color", }; } function getPath (space) { let ret = [space]; for (let s = space; s = s.base;) { ret.push(s); } return ret; } function processFormat (format, {coords} = {}) { if (format.coords && !format.coordGrammar) { format.type ||= "function"; format.name ||= "color"; // Format has not been processed format.coordGrammar = parseCoordGrammar(format.coords); let coordFormats = Object.entries(coords).map(([id, coordMeta], i) => { // Preferred format for each coord is the first one let outputType = format.coordGrammar[i][0]; let fromRange = coordMeta.range || coordMeta.refRange; let toRange = outputType.range, suffix = ""; // Non-strict equals intentional since outputType could be a string object if (outputType == "<percentage>") { toRange = [0, 100]; suffix = "%"; } else if (outputType == "<angle>") { suffix = "deg"; } return {fromRange, toRange, suffix}; }); format.serializeCoords = (coords, precision) => { return coords.map((c, i) => { let {fromRange, toRange, suffix} = coordFormats[i]; if (fromRange && toRange) { c = mapRange(fromRange, toRange, c); } c = serializeNumber(c, {precision, unit: suffix}); return c; }); }; } return format; } var xyz_d65 = new ColorSpace({ id: "xyz-d65", name: "XYZ D65", coords: { x: {name: "X"}, y: {name: "Y"}, z: {name: "Z"}, }, white: "D65", formats: { color: { ids: ["xyz-d65", "xyz"], }, }, aliases: ["xyz"], }); /** * Convenience class for RGB color spaces * @extends {ColorSpace} */ class RGBColorSpace extends ColorSpace { /** * Creates a new RGB ColorSpace. * If coords are not specified, they will use the default RGB coords. * Instead of `fromBase()` and `toBase()` functions, * you can specify to/from XYZ matrices and have `toBase()` and `fromBase()` automatically generated. * @param {*} options - Same options as {@link ColorSpace} plus: * @param {number[][]} options.toXYZ_M - Matrix to convert to XYZ * @param {number[][]} options.fromXYZ_M - Matrix to convert from XYZ */ constructor (options) { if (!options.coords) { options.coords = { r: { range: [0, 1], name: "Red", }, g: { range: [0, 1], name: "Green", }, b: { range: [0, 1], name: "Blue", }, }; } if (!options.base) { options.base = xyz_d65; } if (options.toXYZ_M && options.fromXYZ_M) { options.toBase ??= rgb => { let xyz = multiplyMatrices(options.toXYZ_M, rgb); if (this.white !== this.base.white) { // Perform chromatic adaptation xyz = adapt$2(this.white, this.base.white, xyz); } return xyz; }; options.fromBase ??= xyz => { xyz = adapt$2(this.base.white, this.white, xyz); return multiplyMatrices(options.fromXYZ_M, xyz); }; } options.referred ??= "display"; super(options); } } /** * Get the coordinates of a color in any color space * @param {Color} color * @param {string | ColorSpace} [space = color.space] The color space to convert to. Defaults to the color's current space * @returns {number[]} The color coordinates in the given color space */ function getAll (color, space) { color = getColor(color); if (!space || color.space.equals(space)) { // No conversion needed return color.coords.slice(); } space = ColorSpace.get(space); return space.from(color); } function get (color, prop) { color = getColor(color); let {space, index} = ColorSpace.resolveCoord(prop, color.space); let coords = getAll(color, space); return coords[index]; } function setAll (color, space, coords) { color = getColor(color); space = ColorSpace.get(space); color.coords = space.to(color.space, coords); return color; } setAll.returns = "color"; // Set properties and return current instance function set (color, prop, value) { color = getColor(color); if (arguments.length === 2 && type(arguments[1]) === "object") { // Argument is an object literal let object = arguments[1]; for (let p in object) { set(color, p, object[p]); } } else { if (typeof value === "function") { value = value(get(color, prop)); } let {space, index} = ColorSpace.resolveCoord(prop, color.space); let coords = getAll(color, space); coords[index] = value; setAll(color, space, coords); } return color; } set.returns = "color"; var XYZ_D50 = new ColorSpace({ id: "xyz-d50", name: "XYZ D50", white: "D50", base: xyz_d65, fromBase: coords => adapt$2(xyz_d65.white, "D50", coords), toBase: coords => adapt$2("D50", xyz_d65.white, coords), }); // κ * ε = 2^3 = 8 const ε$6 = 216 / 24389; // 6^3/29^3 == (24/116)^3 const ε3$1 = 24 / 116; const κ$4 = 24389 / 27; // 29^3/3^3 let white$4 = WHITES.D50; var lab = new ColorSpace({ id: "lab", name: "Lab", coords: { l: { refRange: [0, 100], name: "Lightness", }, a: { refRange: [-125, 125], }, b: { refRange: [-125, 125], }, }, // Assuming XYZ is relative to D50, convert to CIE Lab // from CIE standard, which now defines these as a rational fraction white: white$4, base: XYZ_D50, // Convert D50-adapted XYX to Lab // CIE 15.3:2004 section 8.2.1.1 fromBase (XYZ) { // compute xyz, which is XYZ scaled relative to reference white let xyz = XYZ.map((value, i) => value / white$4[i]); // now compute f let f = xyz.map(value => value > ε$6 ? Math.cbrt(value) : (κ$4 * value + 16) / 116); return [ (116 * f[1]) - 16, // L 500 * (f[0] - f[1]), // a 200 * (f[1] - f[2]), // b ]; }, // Convert Lab to D50-adapted XYZ // Same result as CIE 15.3:2004 Appendix D although the derivation is different // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html toBase (Lab) { // compute f, starting with the luminance-related term let f = []; f[1] = (Lab[0] + 16) / 116; f[0] = Lab[1] / 500 + f[1]; f[2] = f[1] - Lab[2] / 200; // compute xyz let xyz = [ f[0] > ε3$1 ? Math.pow(f[0], 3) : (116 * f[0] - 16) / κ$4, Lab[0] > 8 ? Math.pow((Lab[0] + 16) / 116, 3) : Lab[0] / κ$4, f[2] > ε3$1 ? Math.pow(f[2], 3) : (116 * f[2] - 16) / κ$4, ]; // Compute XYZ by scaling xyz by reference white return xyz.map((value, i) => value * white$4[i]); }, formats: { "lab": { coords: ["<number> | <percentage>", "<number> | <percentage>[-1,1]", "<number> | <percentage>[-1,1]"], }, }, }); function constrain (angle) { return ((angle % 360) + 360) % 360; } function adjust (arc, angles) { if (arc === "raw") { return angles; } let [a1, a2] = angles.map(constrain); let angleDiff = a2 - a1; if (arc === "increasing") { if (angleDiff < 0) { a2 += 360; } } else if (arc === "decreasing") { if (angleDiff > 0) { a1 += 360; } } else if (arc === "longer") { if (-180 < angleDiff && angleDiff < 180) { if (angleDiff > 0) { a1 += 360; } else { a2 += 360; } } } else if (arc === "shorter") { if (angleDiff > 180) { a1 += 360; } else if (angleDiff < -180) { a2 += 360; } } return [a1, a2]; } var lch = new ColorSpace({ id: "lch", name: "LCH", coords: { l: { refRange: [0, 100], name: "Lightness", }, c: { refRange: [0, 150], name: "Chroma", }, h: { refRange: [0, 360], type: "angle", name: "Hue", }, }, base: lab, fromBase (Lab) { // Convert to polar form let [L, a, b] = Lab; let hue; const ε = 0.02; if (Math.abs(a) < ε && Math.abs(b) < ε) { hue = NaN; } else { hue = Math.atan2(b, a) * 180 / Math.PI; } return [ L, // L is still L Math.sqrt(a ** 2 + b ** 2), // Chroma constrain(hue), // Hue, in degrees [0 to 360) ]; }, toBase (LCH) { // Convert from polar form let [Lightness, Chroma, Hue] = LCH; // Clamp any negative Chroma if (Chroma < 0) { Chroma = 0; } // Deal with NaN Hue if (isNaN(Hue)) { Hue = 0; } return [ Lightness, // L is still L Chroma * Math.cos(Hue * Math.PI / 180), // a Chroma * Math.sin(Hue * Math.PI / 180), // b ]; }, formats: { "lch": { coords: ["<number> | <percentage>", "<number> | <percentage>", "<number> | <angle>"], }, }, }); // deltaE2000 is a statistically significant improvement // and is recommended by the CIE and Idealliance // especially for color differences less than 10 deltaE76 // but is wicked complicated // and many implementations have small errors! // DeltaE2000 is also discontinuous; in case this // matters to you, use deltaECMC instead. const Gfactor = 25 ** 7; const π$1 = Math.PI; const r2d = 180 / π$1; const d2r$1 = π$1 / 180; function pow7 (x) { // Faster than x ** 7 or Math.pow(x, 7) const x2 = x * x; const x7 = x2 * x2 * x2 * x; return x7; } function deltaE2000 (color, sample, {kL = 1, kC = 1, kH = 1} = {}) { [color, sample] = getColor([color, sample]); // Given this color as the reference // and the function parameter as the sample, // calculate deltaE 2000. // This implementation assumes the parametric // weighting factors kL, kC and kH // for the influence of viewing conditions // are all 1, as sadly seems typical. // kL should be increased for lightness texture or noise // and kC increased for chroma noise let [L1, a1, b1] = lab.from(color); let C1 = lch.from(lab, [L1, a1, b1])[1]; let [L2, a2, b2] = lab.from(sample); let C2 = lch.from(lab, [L2, a2, b2])[1]; // Check for negative Chroma, // which might happen through // direct user input of LCH values if (C1 < 0) { C1 = 0; } if (C2 < 0) { C2 = 0; } let Cbar = (C1 + C2) / 2; // mean Chroma // calculate a-axis asymmetry factor from mean Chroma // this turns JND ellipses for near-neutral colors back into circles let C7 = pow7(Cbar); let G = 0.5 * (1 - Math.sqrt(C7 / (C7 + Gfactor))); // scale a axes by asymmetry factor // this by the way is why there is no Lab2000 colorspace let adash1 = (1 + G) * a1; let adash2 = (1 + G) * a2; // calculate new Chroma from scaled a and original b axes let Cdash1 = Math.sqrt(adash1 ** 2 + b1 ** 2); let Cdash2 = Math.sqrt(adash2 ** 2 + b2 ** 2); // calculate new hues, with zero hue for true neutrals // and in degrees, not radians let h1 = (adash1 === 0 && b1 === 0) ? 0 : Math.atan2(b1, adash1); let h2 = (adash2 === 0 && b2 === 0) ? 0 : Math.atan2(b2, adash2); if (h1 < 0) { h1 += 2 * π$1; } if (h2 < 0) { h2 += 2 * π$1; } h1 *= r2d; h2 *= r2d; // Lightness and Chroma differences; sign matters let ΔL = L2 - L1; let ΔC = Cdash2 - Cdash1; // Hue difference, getting the sign correct let hdiff = h2 - h1; let hsum = h1 + h2; let habs = Math.abs(hdiff); let Δh; if (Cdash1 * Cdash2 === 0) { Δh = 0; } else if (habs <= 180) { Δh = hdiff; } else if (hdiff > 180) { Δh = hdiff - 360; } else if (hdiff < -180) { Δh = hdiff + 360; } else { defaults.warn("the unthinkable has happened"); } // weighted Hue difference, more for larger Chroma let ΔH = 2 * Math.sqrt(Cdash2 * Cdash1) * Math.sin(Δh * d2r$1 / 2); // calculate mean Lightness and Chroma let Ldash = (L1 + L2) / 2; let Cdash = (Cdash1 + Cdash2) / 2; let Cdash7 = pow7(Cdash); // Compensate for non-linearity in the blue region of Lab. // Four possibilities for hue weighting factor, // depending on the angles, to get the correct sign let hdash; if (Cdash1 * Cdash2 === 0) { hdash = hsum; // which should be zero } else if (habs <= 180) { hdash = hsum / 2; } else if (hsum < 360) { hdash = (hsum + 360) / 2; } else { hdash = (hsum - 360) / 2; } // positional corrections to the lack of uniformity of CIELAB // These are all trying to make JND ellipsoids more like spheres // SL Lightness crispening factor // a background with L=50 is assumed let lsq = (Ldash - 50) ** 2; let SL = 1 + ((0.015 * lsq) / Math.sqrt(20 + lsq)); // SC Chroma factor, similar to those in CMC and deltaE 94 formulae let SC = 1 + 0.045 * Cdash; // Cross term T for blue non-linearity let T = 1; T -= (0.17 * Math.cos(( hdash - 30) * d2r$1)); T += (0.24 * Math.cos( 2 * hdash * d2r$1)); T += (0.32 * Math.cos(((3 * hdash) + 6) * d2r$1)); T -= (0.20 * Math.cos(((4 * hdash) - 63) * d2r$1)); // SH Hue factor depends on Chroma, // as well as adjusted hue angle like deltaE94. let SH = 1 + 0.015 * Cdash * T; // RT Hue rotation term compensates for rotation of JND ellipses // and Munsell constant hue lines // in the medium-high Chroma blue region // (Hue 225 to 315) let Δθ = 30 * Math.exp(-1 * (((hdash - 275) / 25) ** 2)); let RC = 2 * Math.sqrt(Cdash7 / (Cdash7 + Gfactor)); let RT = -1 * Math.sin(2 * Δθ * d2r$1) * RC; // Finally calculate the deltaE, term by term as root sume of squares let dE = (ΔL / (kL * SL)) ** 2; dE += (ΔC / (kC * SC)) ** 2; dE += (ΔH / (kH * SH)) ** 2; dE += RT * (ΔC / (kC * SC)) * (ΔH / (kH * SH)); return Math.sqrt(dE); // Yay!!! } // Recalculated for consistent reference white // see https://github.com/w3c/csswg-drafts/issues/6642#issuecomment-943521484 const XYZtoLMS_M$1 = [ [ 0.8190224379967030, 0.3619062600528904, -0.1288737815209879 ], [ 0.0329836539323885, 0.9292868615863434, 0.0361446663506424 ], [ 0.0481771893596242, 0.2642395317527308, 0.6335478284694309 ], ]; // inverse of XYZtoLMS_M const LMStoXYZ_M$1 = [ [ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647 ], [ -0.0405757452148008, 1.1122868032803170, -0.0717110580655164 ], [ -0.0763729366746601, -0.4214933324022432, 1.5869240198367816 ], ]; const LMStoLab_M = [ [ 0.2104542683093140, 0.7936177747023054, -0.0040720430116193 ], [ 1.9779985324311684, -2.4285922420485799, 0.4505937096174110 ], [ 0.0259040424655478, 0.7827717124575296, -0.8086757549230774 ], ]; // LMStoIab_M inverted const LabtoLMS_M = [ [ 1.0000000000000000, 0.3963377773761749, 0.2158037573099136 ], [ 1.0000000000000000, -0.1055613458156586, -0.0638541728258133 ], [ 1.0000000000000000, -0.0894841775298119, -1.2914855480194092 ], ]; var OKLab = new ColorSpace({ id: "oklab", name: "Oklab", coords: { l: { refRange: [0, 1], name: "Lightness", }, a: { refRange: [-0.4, 0.4], }, b: { refRange: [-0.4, 0.4], }, }, // Note that XYZ is relative to D65 white: "D65", base: xyz_d65, fromBase (XYZ) { // move to LMS cone domain let LMS = multiplyMatrices(XYZtoLMS_M$1, XYZ); // non-linearity let LMSg = LMS.map(val => Math.cbrt(val)); return multiplyMatrices(LMStoLab_M, LMSg); }, toBase (OKLab) { // move to LMS cone domain let LMSg = multiplyMatrices(LabtoLMS_M, OKLab); // restore linearity let LMS = LMSg.map(val => val ** 3); return multiplyMatrices(LMStoXYZ_M$1, LMS); }, formats: { "oklab": { coords: ["<percentage> | <number>", "<number> | <percentage>[-1,1]", "<number> | <percentage>[-1,1]"], }, }, }); // More accurate color-difference formulae // than the simple 1976 Euclidean distance in CIE Lab function deltaEOK (color, sample) { [color, sample] = getColor([color, sample]); // Given this color as the reference // and a sample, // calculate deltaEOK, term by term as root sum of squares let [L1, a1, b1] = OKLab.from(color); let [L2, a2, b2] = OKLab.from(sample); let ΔL = L1 - L2; let Δa = a1 - a2; let Δb = b1 - b2; return Math.sqrt(ΔL ** 2 + Δa ** 2 + Δb ** 2); } const ε$5 = .000075; /** * Check if a color is in gamut of either its own or another color space * @return {Boolean} Is the color in gamut? */ function inGamut (color, space, {epsilon = ε$5} = {}) { color = getColor(color); if (!space) { space = color.space; } space = ColorSpace.get(space); let coords = color.coords; if (space !== color.space) { coords = space.from(color); } return space.inGamut(coords, {epsilon}); } function clone (color) { return { space: color.space, coords: color.coords.slice(), alpha: color.alpha, }; } /** * Euclidean distance of colors in an arbitrary color space */ function distance (color1, color2, space = "lab") { space = ColorSpace.get(space); // Assume getColor() is called on color in space.from() let coords1 = space.from(color1); let coords2 = space.from(color2); return Math.sqrt(coords1.reduce((acc, c1, i) => { let c2 = coords2[i]; if (isNaN(c1) || isNaN(c2)) { return acc; } return acc + (c2 - c1) ** 2; }, 0)); } function deltaE76 (color, sample) { // Assume getColor() is called in the distance function return distance(color, sample, "lab"); } // More accurate color-difference formulae // than the simple 1976 Euclidean distance in Lab // CMC by the Color Measurement Committee of the // Bradford Society of Dyeists and Colorsts, 1994. // Uses LCH rather than Lab, // with different weights for L, C and H differences // A nice increase in accuracy for modest increase in complexity const π = Math.PI; const d2r = π / 180; function deltaECMC (color, sample, {l = 2, c = 1} = {}) { [color, sample] = getColor([color, sample]); // Given this color as the reference // and a sample, // calculate deltaE CMC. // This implementation assumes the parametric // weighting factors l:c are 2:1 // which is typical for non-textile uses. let [L1, a1, b1] = lab.from(color); let [, C1, H1] = lch.from(lab, [L1, a1, b1]); let [L2, a2, b2] = lab.from(sample); let C2 = lch.from(lab, [L2, a2, b2])[1]; // let [L1, a1, b1] = color.getAll(lab); // let C1 = color.get("lch.c"); // let H1 = color.get("lch.h"); // let [L2, a2, b2] = sample.getAll(lab); // let C2 = sample.get("lch.c"); // Check for negative Chroma, // which might happen through // direct user input of LCH values if (C1 < 0) { C1 = 0; } if (C2 < 0) { C2 = 0; } // we don't need H2 as ΔH is calculated from Δa, Δb and ΔC // Lightness and Chroma differences // These are (color - sample), unlike deltaE2000 let ΔL = L1 - L2; let ΔC = C1 - C2; let Δa = a1 - a2; let Δb = b1 - b2; // weighted Hue difference, less for larger Chroma difference let H2 = (Δa ** 2) + (Δb ** 2) - (ΔC ** 2); // due to roundoff error it is possible that, for zero a and b, // ΔC > Δa + Δb is 0, resulting in attempting // to take the square root of a negative number // trying instead the equation from Industrial Color Physics // By Georg A. Klein // let ΔH = ((a1 * b2) - (a2 * b1)) / Math.sqrt(0.5 * ((C2 * C1) + (a2 * a1) + (b2 * b1))); // console.log({ΔH}); // This gives the same result to 12 decimal places // except it sometimes NaNs when trying to root a negative number // let ΔH = Math.sqrt(H2); we never actually use the root, it gets squared again!! // positional corrections to the lack of uniformity of CIELAB // These are all trying to make JND ellipsoids more like spheres // SL Lightness crispening factor, depends entirely on L1 not L2 let SL = 0.511; // linear portion of the Y to L transfer function if (L1 >= 16) { // cubic portion SL = (0.040975 * L1) / (1 + 0.01765 * L1); } // SC Chroma factor let SC = ((0.0638 * C1) / (1 + 0.0131 * C1)) + 0.638; // Cross term T for blue non-linearity let T; if (Number.isNaN(H1)) { H1 = 0; } if (H1 >= 164 && H1 <= 345) { T = 0.56 + Math.abs(0.2 * Math.cos((H1 + 168) * d2r)); } else { T = 0.36 + Math.abs(0.4 * Math.cos((H1 + 35) * d2r)); } // console.log({T}); // SH Hue factor also depends on C1, let C4 = Math.pow(C1, 4); let F = Math.sqrt(C4 / (C4 + 1900)); let SH = SC * ((F * T) + 1 - F); // Finally calculate the deltaE, term by term as root sume of squares let dE = (ΔL / (l * SL)) ** 2; dE += (ΔC / (c * SC)) ** 2; dE += (H2 / (SH ** 2)); // dE += (ΔH / SH) ** 2; return Math.sqrt(dE); // Yay!!! } const Yw$1 = 203; // absolute luminance of media white var XYZ_Abs_D65 = new ColorSpace({ // Absolute CIE XYZ, with a D65 whitepoint, // as used in most HDR colorspaces as a starting point. // SDR spaces are converted per BT.2048 // so that diffuse, media white is 203 cd/m² id: "xyz-abs-d65", cssId: "--xyz-abs-d65", name: "Absolute XYZ D65", coords: { x: { refRange: [0, 9504.7], name: "Xa", }, y: { refRange: [0, 10000], name: "Ya", }, z: { refRange: [0, 10888.3], name: "Za", }, }, base: xyz_d65, fromBase (XYZ) { // Make XYZ absolute, not relative to media white // Maximum luminance in PQ is 10,000 cd/m² // Relative XYZ has Y=1 for media white return XYZ.map (v => Math.max(v * Yw$1, 0)); }, toBase (AbsXYZ) { // Convert to media-white relative XYZ return AbsXYZ.map(v => Math.max(v / Yw$1, 0)); }, }); const b$1 = 1.15; const g = 0.66; const n$1 = 2610 / (2 ** 14); const ninv$1 = (2 ** 14) / 2610; const c1$2 = 3424 / (2 ** 12); const c2$2 = 2413 / (2 ** 7); const c3$2 = 2392 / (2 ** 7); const p = 1.7 * 2523 / (2 ** 5); const pinv = (2 ** 5) / (1.7 * 2523); const d = -0.56; const d0 = 1.6295499532821566E-11; const XYZtoCone_M = [ [ 0.41478972, 0.579999, 0.0146480 ], [ -0.2015100, 1.120649, 0.0531008 ], [ -0.0166008, 0.264800, 0.6684799 ], ]; // XYZtoCone_M inverted const ConetoXYZ_M = [ [ 1.9242264357876067, -1.0047923125953657, 0.037651404030618 ], [ 0.35031676209499907, 0.7264811939316552, -0.06538442294808501 ], [ -0.09098281098284752, -0.3127282905230739, 1.5227665613052603 ], ]; const ConetoIab_M = [ [ 0.5, 0.5, 0 ], [ 3.524000, -4.066708, 0.542708 ], [ 0.199076, 1.096799, -1.295875 ], ]; // ConetoIab_M inverted const IabtoCone_M = [ [ 1, 0.1386050432715393, 0.05804731615611886 ], [ 0.9999999999999999, -0.1386050432715393, -0.05804731615611886 ], [ 0.9999999999999998, -0.09601924202631895, -0.8118918960560388 ], ]; var Jzazbz = new ColorSpace({ id: "jzazbz", name: "Jzazbz", coords: { jz: { refRange: [0, 1], name: "Jz", }, az: { refRange: [-0.5, 0.5], }, bz: { refRange: [-0.5, 0.5], }, }, base: XYZ_Abs_D65, fromBase (XYZ) { // First make XYZ absolute, not relative to media white // Maximum luminance in PQ is 10,000 cd/m² // Relative XYZ has Y=1 for media white // BT.2048 says media white Y=203 at PQ 58 let [ Xa, Ya, Za ] = XYZ; // modify X and Y let Xm = (b$1 * Xa) - ((b$1 - 1) * Za); let Ym = (g * Ya) - ((g - 1) * Xa); // move to LMS cone domain let LMS = multiplyMatrices(XYZtoCone_M, [ Xm, Ym, Za ]); // PQ-encode LMS let PQLMS = LMS.map (function (val) { let num = c1$2 + (c2$2 * ((val / 10000) ** n$1)); let denom = 1 + (c3$2 * ((val / 10000) ** n$1)); return (num / denom) ** p; }); // almost there, calculate Iz az bz let [ Iz, az, bz] = multiplyMatrices(ConetoIab_M, PQLMS); // console.log({Iz, az, bz}); let Jz = ((1 + d) * Iz) / (1 + (d * Iz)) - d0; return [Jz, az, bz]; }, toBase (Jzazbz) { let [Jz, az, bz] = Jzazbz; let Iz = (Jz + d0) / (1 + d - d * (Jz + d0)); // bring into LMS cone domain let PQLMS = multiplyMatrices(IabtoCone_M, [ Iz, az, bz ]); // convert from PQ-coded to linear-light let LMS = PQLMS.map(function (val) { let num = (c1$2 - (val ** pinv)); let denom = (c3$2 * (val ** pinv)) - c2$2; let x = 10000 * ((num / denom) ** ninv$1); return (x); // luminance relative to diffuse white, [0, 70 or so]. }); // modified abs XYZ let [ Xm, Ym, Za ] = multiplyMatrices(ConetoXYZ_M, LMS); // restore standard D50 relative XYZ, relative to media white let Xa = (Xm + ((b$1 - 1) * Za)) / b$1; let Ya = (Ym + ((g - 1) * Xa)) / g; return [ Xa, Ya, Za ]; }, formats: { // https://drafts.csswg.org/css-color-hdr/#Jzazbz "color": { coords: ["<number> | <percentage>", "<number> | <percentage>[-1,1]", "<number> | <percentage>[-1,1]"], }, }, }); var jzczhz = new ColorSpace({ id: "jzczhz", name: "JzCzHz", coords: { jz: { refRange: [0, 1], name: "Jz", }, cz: { refRange: [0, 1], name: "Chroma", }, hz: { refRange: [0, 360], type: "angle", name: "Hue", }, }, base: Jzazbz, fromBase (jzazbz) { // Convert to polar form let [Jz, az, bz] = jzazbz; let hue; const ε = 0.0002; // chromatic components much smaller than a,b if (Math.abs(az) < ε && Math.abs(bz) < ε) { hue = NaN; } else { hue = Math.atan2(bz, az) * 180 / Math.PI; } return [ Jz, // Jz is still Jz Math.sqrt(az ** 2 + bz ** 2), // Chroma constrain(hue), // Hue, in degrees [0 to 360) ]; }, toBase (jzczhz) { // Convert from polar form // debugger; return [ jzczhz[0], // Jz is still Jz jzczhz[1] * Math.cos(jzczhz[2] * Math.PI / 180), // az jzczhz[1] * Math.sin(jzczhz[2] * Math.PI / 180), // bz ]; }, }); // More accurate color-difference formulae // than the simple 1976 Euclidean distance in Lab // Uses JzCzHz, which has improved perceptual uniformity // and thus a simple Euclidean root-sum of ΔL² ΔC² ΔH² // gives good results. function deltaEJz (color, sample) { [color, sample] = getColor([color, sample]); // Given this color as the reference // and a sample, // calculate deltaE in JzCzHz. let [Jz1, Cz1, Hz1] = jzczhz.from(color); let [Jz2, Cz2, Hz2] = jzczhz.from(sample); // Lightness and Chroma differences // sign does not matter as they are squared. let ΔJ = Jz1 - Jz2; let ΔC = Cz1 - Cz2; // length of chord for ΔH if ((Number.isNaN(Hz1)) && (Number.isNaN(Hz2))) { // both undefined hues Hz1 = 0; Hz2 = 0; } else if (Number.isNaN(Hz1)) { // one undefined, set to the defined hue Hz1 = Hz2; } else if (Number.isNaN(Hz2)) { Hz2 = Hz1; } let Δh = Hz1 - Hz2; let ΔH = 2 * Math.sqrt(Cz1 * Cz2) * Math.sin((Δh / 2) * (Math.PI / 180)); return Math.sqrt(ΔJ ** 2 + ΔC ** 2 + ΔH ** 2); } const c1$1 = 3424 / 4096; const c2$1 = 2413 / 128; const c3$1 = 2392 / 128; const m1$1 = 2610 / 16384; const m2 = 2523 / 32; const im1 = 16384 / 2610; const im2 = 32 / 2523; // The matrix below includes the 4% crosstalk components // and is from the Dolby "W