Skip to content

Commit ae276da

Browse files
LeaVerouclaude
andauthored
Add gamut-relative OKLCh & LCH color spaces (GamutRelativeColorSpace) (#736)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14b3473 commit ae276da

13 files changed

Lines changed: 534 additions & 0 deletions

docs/images/spaces.d2

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,39 @@ node-lab -> node-lch {
311311
}
312312

313313

314+
node-lch-p3: LCH P3 {
315+
link: "#lch-p3"
316+
class: [node
317+
318+
]
319+
}
320+
node-lch -> node-lch-p3 {
321+
class: edge
322+
}
323+
324+
325+
node-lch-rec2020: LCH Rec.2020 {
326+
link: "#lch-rec2020"
327+
class: [node
328+
329+
]
330+
}
331+
node-lch -> node-lch-rec2020 {
332+
class: edge
333+
}
334+
335+
336+
node-lch-srgb: LCH sRGB {
337+
link: "#lch-srgb"
338+
class: [node
339+
340+
]
341+
}
342+
node-lch -> node-lch-srgb {
343+
class: edge
344+
}
345+
346+
314347
node-lchuv: LChuv {
315348
link: "#lchuv"
316349
class: [node
@@ -366,6 +399,39 @@ node-oklab -> node-oklch {
366399
}
367400

368401

402+
node-oklch-p3: OKLCh P3 {
403+
link: "#oklch-p3"
404+
class: [node
405+
406+
]
407+
}
408+
node-oklch -> node-oklch-p3 {
409+
class: edge
410+
}
411+
412+
413+
node-oklch-rec2020: OKLCh Rec.2020 {
414+
link: "#oklch-rec2020"
415+
class: [node
416+
417+
]
418+
}
419+
node-oklch -> node-oklch-rec2020 {
420+
class: edge
421+
}
422+
423+
424+
node-oklch-srgb: OKLCh sRGB {
425+
link: "#oklch-srgb"
426+
class: [node
427+
428+
]
429+
}
430+
node-oklch -> node-oklch-srgb {
431+
class: edge
432+
}
433+
434+
369435
node-oklab: Oklab {
370436
link: "#oklab"
371437
class: [node

releases/v0.7.0-alpha.2.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# v0.7.0-alpha.2
2+
3+
The second pre-release of v0.7.0.
4+
5+
## New features
6+
7+
- 🆕 **New color spaces** Gamut-relative OKLCh and LCH spaces, where `c = 1` is the most colorful in-gamut color at a given lightness and hue (so `c` reads as a 0–100% "how colorful for this gamut"): `oklch-p3`, `oklch-srgb`, `oklch-rec2020`, `lch-p3`, `lch-srgb`, `lch-rec2020`. Built on a new general `GamutRelativeColorSpace` class (by @LeaVerou in #736)
8+
9+
**Full Changelog**: https://github.com/color-js/color.js/compare/v0.7.0-alpha.1...v0.7.0-alpha.2

src/GamutRelativeColorSpace.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import ColorSpace from "./ColorSpace.js";
2+
import toGamut from "./toGamut.js";
3+
import { isNone } from "./util.js";
4+
5+
/** @import { SpaceOptions } from "./ColorSpace.js" */
6+
7+
/**
8+
* A color space whose chroma-like coordinate is expressed relative to an RGB gamut: chroma = 1 is
9+
* the most colorful in-gamut color for the rest of the coordinates, so any chroma in [0, 1] stays
10+
* in gamut. The reduced coordinate (`chroma`, default `"c"`) is rescaled against `gamutSpace`; every
11+
* other coordinate is inherited unchanged from `base`. Works for any base whose chroma coordinate
12+
* brings the color into gamut as it is reduced (e.g. OKLCh, LCH).
13+
*/
14+
export default class GamutRelativeColorSpace extends ColorSpace {
15+
/**
16+
* @param {SpaceOptions & { gamutSpace: string | ColorSpace, chroma?: string }} options
17+
* Requires `base` (the source space) and `gamutSpace` (the RGB gamut). `chroma` names the
18+
* coordinate to rescale (default `"c"`).
19+
*/
20+
constructor (options) {
21+
let base = ColorSpace.get(options.base);
22+
let chroma = options.chroma ?? "c";
23+
let ids = Object.keys(base.coords);
24+
let chromaIndex = ids.indexOf(chroma);
25+
26+
if (chromaIndex === -1) {
27+
throw new TypeError(
28+
`GamutRelativeColorSpace needs a "${chroma}" coordinate, got ${base.id}`,
29+
);
30+
}
31+
32+
// Inherit the base's coords; the reduced coordinate becomes a 0–1 fraction of its gamut maximum
33+
let coords = Object.fromEntries(
34+
Object.entries(base.coords).map(([id, meta]) => [id, { ...meta }]),
35+
);
36+
coords[chroma].range = [0, 1];
37+
delete coords[chroma].refRange;
38+
options.coords ??= coords;
39+
40+
// Serialize the reduced coordinate and any other 0–1 coordinate as percentages; angles as angles
41+
options.formats ??= {
42+
color: {
43+
coords: ids.map(id => {
44+
if (base.coords[id].type === "angle") {
45+
return "<number> | <angle>";
46+
}
47+
let [min, max] = base.coords[id].range ?? base.coords[id].refRange ?? [];
48+
let pctFirst = id === chroma || (min === 0 && max === 1);
49+
return pctFirst ? "<percentage> | <number>" : "<number> | <percentage>";
50+
}),
51+
},
52+
};
53+
54+
super(options);
55+
56+
this.chromaIndex = chromaIndex;
57+
this.method = `${base.id}.${chroma}`;
58+
59+
// A chroma that is out of gamut at every lightness and hue: the gamut's most saturated corner
60+
// (its primaries/secondaries are its chroma maxima) plus headroom. Seeding the gamut search
61+
// from here lands on the first in-gamut crossing — the highest contiguous in-gamut chroma —
62+
// and avoids clamping wide gamuts (e.g. Rec.2020 exceeds OKLCh's nominal 0.4 chroma).
63+
// prettier-ignore
64+
let corners = /** @type {[number, number, number][]} */ (
65+
[[0, 0, 1], [0, 1, 0], [0, 1, 1], [1, 0, 0], [1, 0, 1], [1, 1, 0]]
66+
);
67+
this.oogChroma =
68+
1.01 *
69+
Math.max(...corners.map(rgb => base.from(this.gamutSpace, rgb)[this.chromaIndex] || 0));
70+
}
71+
72+
/**
73+
* The highest in-gamut value of the reduced coordinate for the given coordinates. The reduced
74+
* coordinate's own value is ignored; the others determine the result.
75+
* @param {number[]} coords
76+
* @returns {number}
77+
*/
78+
maxChroma (coords) {
79+
let seed = /** @type {[number, number, number]} */ ([...coords]);
80+
seed[this.chromaIndex] = this.oogChroma;
81+
82+
return toGamut(
83+
{ space: this.base, coords: seed },
84+
// deltaE OK stays sensitive near black, where deltaE 2000 is too forgiving of excess chroma
85+
{ space: this.gamutSpace, method: this.method, deltaEMethod: "OK", jnd: 0 },
86+
).coords[this.chromaIndex];
87+
}
88+
89+
toBase (coords) {
90+
coords = [...coords];
91+
92+
if (coords.some((c, i) => i !== this.chromaIndex && isNone(c))) {
93+
// chroma is a fraction of maxChroma(the other coords); without them all it is undefined
94+
if (coords[this.chromaIndex] !== 0) {
95+
coords[this.chromaIndex] = null;
96+
}
97+
}
98+
else if (!isNone(coords[this.chromaIndex])) {
99+
coords[this.chromaIndex] *= this.maxChroma(coords);
100+
}
101+
102+
return coords;
103+
}
104+
105+
fromBase (coords) {
106+
coords = [...coords];
107+
108+
if (coords.some((c, i) => i !== this.chromaIndex && isNone(c))) {
109+
if (coords[this.chromaIndex] !== 0) {
110+
coords[this.chromaIndex] = null;
111+
}
112+
}
113+
else if (!isNone(coords[this.chromaIndex])) {
114+
let cMax = this.maxChroma(coords);
115+
coords[this.chromaIndex] = cMax ? coords[this.chromaIndex] / cMax : 0;
116+
}
117+
118+
return coords;
119+
}
120+
}

src/index-fn.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
export { default as ColorSpace } from "./ColorSpace.js";
99
export { default as RGBColorSpace } from "./RGBColorSpace.js";
10+
export { default as GamutRelativeColorSpace } from "./GamutRelativeColorSpace.js";
1011
export { default as hooks, Hooks } from "./hooks.js";
1112
export { default as defaults } from "./defaults.js";
1213
export { default as getColor } from "./getColor.js";

src/space-coord-accessors.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,18 @@ declare class SpaceAccessors {
2727
lab: SpaceAccessor;
2828
lab_d65: SpaceAccessor;
2929
lch: SpaceAccessor;
30+
lch_p3: SpaceAccessor;
31+
lch_rec2020: SpaceAccessor;
32+
lch_srgb: SpaceAccessor;
3033
lchuv: SpaceAccessor;
3134
luv: SpaceAccessor;
3235
okhsl: SpaceAccessor;
3336
okhsv: SpaceAccessor;
3437
oklab: SpaceAccessor;
3538
oklch: SpaceAccessor;
39+
oklch_p3: SpaceAccessor;
40+
oklch_rec2020: SpaceAccessor;
41+
oklch_srgb: SpaceAccessor;
3642
oklrab: SpaceAccessor;
3743
oklrch: SpaceAccessor;
3844
p3: SpaceAccessor;

src/spaces/index-fn.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export { default as REC_2020 } from "./rec2020.js";
2626
export { default as REC_2020_Scene_Referred } from "./rec2020-oetf.js";
2727
export { default as OKLab } from "./oklab.js";
2828
export { default as OKLCH } from "./oklch.js";
29+
export { default as OKLCH_P3 } from "./oklch-p3.js";
30+
export { default as OKLCH_sRGB } from "./oklch-srgb.js";
31+
export { default as OKLCH_REC_2020 } from "./oklch-rec2020.js";
32+
export { default as LCH_P3 } from "./lch-p3.js";
33+
export { default as LCH_sRGB } from "./lch-srgb.js";
34+
export { default as LCH_REC_2020 } from "./lch-rec2020.js";
2935
export { default as OKLrab } from "./oklrab.js";
3036
export { default as OKLrCH } from "./oklrch.js";
3137
export { default as Okhsl } from "./okhsl.js";

src/spaces/lch-p3.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import GamutRelativeColorSpace from "../GamutRelativeColorSpace.js";
2+
import LCH from "./lch.js";
3+
import P3 from "./p3.js";
4+
5+
export default new GamutRelativeColorSpace({
6+
id: "lch-p3",
7+
cssId: "--lch-p3",
8+
name: "LCH P3",
9+
base: LCH,
10+
gamutSpace: P3,
11+
});

src/spaces/lch-rec2020.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import GamutRelativeColorSpace from "../GamutRelativeColorSpace.js";
2+
import LCH from "./lch.js";
3+
import REC2020 from "./rec2020.js";
4+
5+
export default new GamutRelativeColorSpace({
6+
id: "lch-rec2020",
7+
cssId: "--lch-rec2020",
8+
name: "LCH Rec.2020",
9+
base: LCH,
10+
gamutSpace: REC2020,
11+
});

src/spaces/lch-srgb.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import GamutRelativeColorSpace from "../GamutRelativeColorSpace.js";
2+
import LCH from "./lch.js";
3+
import sRGB from "./srgb.js";
4+
5+
export default new GamutRelativeColorSpace({
6+
id: "lch-srgb",
7+
cssId: "--lch-srgb",
8+
name: "LCH sRGB",
9+
base: LCH,
10+
gamutSpace: sRGB,
11+
});

src/spaces/oklch-p3.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import GamutRelativeColorSpace from "../GamutRelativeColorSpace.js";
2+
import OKLCH from "./oklch.js";
3+
import P3 from "./p3.js";
4+
5+
export default new GamutRelativeColorSpace({
6+
id: "oklch-p3",
7+
cssId: "--oklch-p3",
8+
name: "OKLCh P3",
9+
base: OKLCH,
10+
gamutSpace: P3,
11+
});

0 commit comments

Comments
 (0)