diff --git a/package.json b/package.json index 7bbdd3a98..0580cb4c0 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,30 @@ "files": [ "dist/", "src/", - "types/dist/", "types/src/", - "types/index.d.ts" + "types/index.d.ts", + "types/index.d.cts" ], "exports": { ".": { - "types": "./types/index.d.ts", - "import": "./dist/color.js", - "require": "./dist/color.cjs" + "import": { + "types": "./types/index.d.ts", + "default": "./dist/color.js" + }, + "require": { + "types": "./types/index.d.cts", + "default": "./dist/color.cjs" + } }, "./fn": { - "types": "./types/src/index-fn.d.ts", - "import": "./src/index-fn.js", - "require": "./dist/color-fn.cjs" + "import": { + "types": "./types/src/index-fn.d.ts", + "default": "./src/index-fn.js" + }, + "require": { + "types": "./types/src/index-fn.d.cts", + "default": "./dist/color-fn.cjs" + } }, "./dist/*": "./dist/*" }, diff --git a/src/space.js b/src/space.js index a0bf2d27d..bae6569a4 100644 --- a/src/space.js +++ b/src/space.js @@ -53,6 +53,30 @@ export default class ColorSpace { this.formats.color.id = 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; @@ -68,11 +92,9 @@ export default class ColorSpace { } inGamut (coords, {epsilon = ε} = {}) { - if (this.isPolar) { - // Do not check gamut through polar coordinates - coords = this.toBase(coords); - - return this.base.inGamut(coords, {epsilon}); + if (!this.equals(this.gamutSpace)) { + coords = this.to(this.gamutSpace, coords); + return this.gamutSpace.inGamut(coords, {epsilon}); } let coordMeta = Object.values(this.coords); diff --git a/src/spaces/hct.js b/src/spaces/hct.js new file mode 100644 index 000000000..53d313477 --- /dev/null +++ b/src/spaces/hct.js @@ -0,0 +1,154 @@ +import ColorSpace from "../space.js"; +import {constrain} from "../angles.js"; +import xyz_d65 from "./xyz-d65.js"; +import {fromCam16, toCam16, environment} from "./cam16.js"; +import {WHITES} from "../adapt.js"; + +const white = WHITES.D65; +const ε = 216 / 24389; // 6^3/29^3 == (24/116)^3 +const κ = 24389 / 27; // 29^3/3^3 + +function toLstar (y) { + // Convert XYZ Y to L* + + const fy = (y > ε) ? Math.cbrt(y) : (κ * y + 16) / 116; + return (116.0 * fy) - 16.0; +} + +function fromLstar (lstar) { + // Convert L* back to XYZ Y + + return (lstar > 8) ? Math.pow((lstar + 16) / 116, 3) : lstar / κ; +} + +function fromHct (coords, env) { + // Use Newton's method to try and converge as quick as possible or + // converge as close as we can. While the requested precision is achieved + // most of the time, it may not always be achievable. Especially past the + // visible spectrum, the algorithm will likely struggle to get the same + // precision. If, for whatever reason, we cannot achieve the accuracy we + // seek in the allotted iterations, just return the closest we were able to + // get. + + let [h, c, t] = coords; + let xyz = []; + let j = 0; + + // Shortcut out for black + if (t === 0) { + return [0.0, 0.0, 0.0]; + } + + // Calculate the Y we need to target + let y = fromLstar(t); + + // A better initial guess yields better results. Polynomials come from + // curve fitting the T vs J response. + if (t > 0) { + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; + } + else { + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - 21.928975842194614; + } + + // Threshold of how close is close enough, and max number of attempts. + // More precision and more attempts means more time spent iterating. Higher + // required precision gives more accuracy but also increases the chance of + // not hitting the goal. 2e-12 allows us to convert round trip with + // reasonable accuracy of six decimal places or more. + const threshold = 2e-12; + const max_attempts = 15; + + let attempt = 0; + let last = Infinity; + let best = j; + + // Try to find a J such that the returned y matches the returned y of the L* + while (attempt <= max_attempts) { + xyz = fromCam16({J: j, C: c, h: h}, env); + + // If we are within range, return XYZ + // If we are closer than last time, save the values + const delta = Math.abs(xyz[1] - y); + if (delta < last) { + if (delta <= threshold) { + return xyz; + } + best = j; + last = delta; + } + + // f(j_root) = (j ** (1 / 2)) * 0.1 + // f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + // f(j_root) = Y = y / 100 + // f(j) = (y ** 2) / j - 1 + // f'(j) = (2 * y) / j + j = j - (xyz[1] - y) * j / (2 * xyz[1]); + + attempt += 1; + } + + // We could not acquire the precision we desired, + // return our closest attempt. + return fromCam16({J: j, C: c, h: h}, env); +} + +function toHct (xyz, env) { + // Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue. + + const t = toLstar(xyz[1]); + if (t === 0.0) { + return [0.0, 0.0, 0.0]; + } + const cam16 = toCam16(xyz, viewingConditions); + return [constrain(cam16.h), cam16.C, t]; +} + +// Pre-calculate everything we can with the viewing conditions +export const viewingConditions = environment( + white, 200 / Math.PI * fromLstar(50.0), + fromLstar(50.0) * 100, + "average", + false +); + +// https://material.io/blog/science-of-color-design +// This is not a port of the material-color-utilities, +// but instead implements the full color space as described, +// combining CAM16 JCh and Lab D65. This does not clamp conversion +// to HCT to specific chroma bands and provides support for wider +// gamuts than Google currently supports and does so at a greater +// precision (> 8 bits back to sRGB). +// This implementation comes from https://github.com/facelessuser/coloraide +// which is licensed under MIT. +export default new ColorSpace({ + id: "hct", + name: "HCT", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + c: { + refRange: [0, 145], + name: "Colorfulness", + }, + t: { + refRange: [0, 100], + name: "Tone", + } + }, + + base: xyz_d65, + + fromBase (xyz) { + return toHct(xyz, viewingConditions); + }, + toBase (hct) { + return fromHct(hct, viewingConditions); + }, + formats: { + color: {} + }, +}); diff --git a/src/spaces/index-fn.js b/src/spaces/index-fn.js index 854fcf69c..47696312f 100644 --- a/src/spaces/index-fn.js +++ b/src/spaces/index-fn.js @@ -20,5 +20,6 @@ export {default as REC_2020} from "./rec2020.js"; export {default as OKLab} from "./oklab.js"; export {default as OKLCH} from "./oklch.js"; export {default as CAM16_JMh} from "./cam16.js"; +export {default as HCT} from "./hct.js"; export * from "./index-fn-hdr.js"; diff --git a/src/util.js b/src/util.js index d4f9d9023..bc7fe16ba 100644 --- a/src/util.js +++ b/src/util.js @@ -29,7 +29,7 @@ export function serializeNumber (n, {precision, unit }) { return "none"; } - return toPrecision(n, precision) + (unit ?? ""); + return toPrecision(n, precision) + (unit ?? ""); } /** @@ -55,16 +55,12 @@ export function skipNone (n) { */ export function toPrecision (n, precision) { n = +n; - precision = +precision; - let integerLength = (Math.floor(n) + "").length; - - if (precision > integerLength) { - return +n.toFixed(precision - integerLength); - } - else { - let p10 = 10 ** (integerLength - precision); - return Math.round(n / p10) * p10; + if (n === 0) { + return 0; } + precision = +precision; + const multiplier = Math.pow(10, precision - Math.floor(Math.log10(Math.abs(n))) - 1); + return Math.round(n * multiplier) / multiplier; } const angleFactor = { diff --git a/test/conversions.js b/test/conversions.js index 3ea94b687..606327304 100644 --- a/test/conversions.js +++ b/test/conversions.js @@ -582,6 +582,54 @@ const tests = { } ] }, + { + name: "HCT", + data: { + toSpace: "hct", + }, + tests: [ + { + name: "sRGB white to HCT", + args: "white", + expect: [209.5429, 2.871589, 100.0] + }, + { + name: "sRGB red to HCT", + args: "red", + expect: [27.4098, 113.3564, 53.23712] + }, + { + name: "sRGB lime to HCT", + args: "lime", + expect: [142.1404, 108.4065, 87.73552] + }, + { + name: "sRGB blue to HCT", + args: "blue", + expect: [282.7622, 87.22804, 32.30087] + }, + { + name: "sRGB cyan to HCT", + args: "cyan", + expect: [196.5475, 58.96368, 91.11475] + }, + { + name: "sRGB magenta to HCT", + args: "magenta", + expect: [334.6332, 107.3899, 60.32273] + }, + { + name: "sRGB yellow to HCT", + args: "yellow", + expect: [111.0456, 75.50438, 97.13856] + }, + { + name: "sRGB black to HCT", + args: "black", + expect: [0.0, 0.0, 0.0] + } + ] + }, { name: "Get coordinates", data: { diff --git a/types/dist/color.d.ts b/types/dist/color.d.ts deleted file mode 100644 index 17ee3bb29..000000000 --- a/types/dist/color.d.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../src/index.js"; diff --git a/types/index.d.cts b/types/index.d.cts new file mode 100644 index 000000000..deb42def2 --- /dev/null +++ b/types/index.d.cts @@ -0,0 +1,4 @@ +// Definitions by: Adam Thompson-Sharpe +// Minimum TypeScript Version: 4.1 +export { default } from "./index.js"; +export * from "./index.js"; diff --git a/types/index.d.ts b/types/index.d.ts index dcf144e26..22c41d582 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ // Definitions by: Adam Thompson-Sharpe // Minimum TypeScript Version: 4.1 -export { default } from "./dist/color.js"; +export { default } from "./src/index.js"; + export type { ColorConstructor, ColorObject, diff --git a/types/src/index-fn.d.cts b/types/src/index-fn.d.cts new file mode 100644 index 000000000..cd82875a7 --- /dev/null +++ b/types/src/index-fn.d.cts @@ -0,0 +1 @@ +export * from "./index-fn.js"; diff --git a/types/src/space.d.ts b/types/src/space.d.ts index a0ddaab52..cd9477bb7 100644 --- a/types/src/space.d.ts +++ b/types/src/space.d.ts @@ -37,6 +37,7 @@ export interface Options { cssId?: string | undefined; referred?: string | undefined; formats?: Record | undefined; + gamutSpace?: "self" | string | ColorSpace | null | undefined; } export type Ref = @@ -91,6 +92,7 @@ export default class ColorSpace { formats: Record; referred?: string | undefined; white: White; + gamutSpace: ColorSpace; from (color: Color | ColorObject): Coords; from (space: string | ColorSpace, coords: Coords): Coords;