diff --git a/_headers b/_headers
index cb95f63..17ac4bc 100644
--- a/_headers
+++ b/_headers
@@ -1,2 +1,10 @@
/*
Access-Control-Allow-Origin: *
+
+# Cross-origin isolate the GMA benchmark so performance.now() gets its
+# high-resolution timer (~5µs) instead of the coarsened default (~100µs),
+# for less noisy per-method timings. Safe here because the page and its whole
+# module graph are same-origin, so COEP: require-corp blocks nothing.
+/gamut-mapping/benchmark/*
+ Cross-Origin-Opener-Policy: same-origin
+ Cross-Origin-Embedder-Policy: require-corp
diff --git a/gamut-mapping-gradients/mapped-gradient.js b/gamut-mapping-gradients/mapped-gradient.js
index 86c7186..d14b979 100644
--- a/gamut-mapping-gradients/mapped-gradient.js
+++ b/gamut-mapping-gradients/mapped-gradient.js
@@ -1,4 +1,5 @@
import methods from "../gamut-mapping/methods.js";
+import { serialize } from "colorjs.io/fn";
export default {
props: {
@@ -24,22 +25,20 @@ export default {
methods: {
mapSteps () {
const start = performance.now();
- let steps = this.steps.map(step => {
- let mappedColor;
+ let mapped = this.steps.map(step => {
if (this.method === "none") {
return step;
}
if (methods[this.method].compute) {
- mappedColor = methods[this.method].compute(step);
+ return methods[this.method].compute(step);
}
- else {
- mappedColor = step.clone().toGamut({ space: "p3", method: this.method });
- }
- return mappedColor;
+ return step.clone().toGamut({ space: "p3", method: this.method });
});
this.time = Color.util.toPrecision(performance.now() - start, 4);
this.$emit("report-time", {time: this.time, method: this.method});
- this.mappedSteps = steps;
+ // compute() returns plain color objects now; serialize outside the timed
+ // region so the gradient's --step-color CSS variable gets a color string.
+ this.mappedSteps = mapped.map(color => serialize(color));
},
},
diff --git a/gamut-mapping/benchmark/index.js b/gamut-mapping/benchmark/index.js
index 906ab51..5bee06c 100644
--- a/gamut-mapping/benchmark/index.js
+++ b/gamut-mapping/benchmark/index.js
@@ -1,4 +1,5 @@
import Color from "colorjs.io";
+import { to, serialize, OKLCH, P3 } from "colorjs.io/fn";
import { representatives } from "../methods.js";
import { mapColor, getDeltas, defaultWeights as weights } from "../map.js";
import stats, { average } from "../stats.js";
@@ -40,6 +41,13 @@ const FRAME_BUDGET = 12; // ms
const prec = Color.util.toPrecision;
+// A plain OKLCh color object for the sweep's fixed input chroma. Procedural
+// Color.js consumes these directly, with none of the per-color OOP overhead
+// (getter/setter definition, result re-wrapping) that would otherwise skew the
+// per-method timings. The space is the OKLCH object (not a string id), so no
+// registry lookup happens on the timed path.
+let oklchColor = (l, h) => ({space: OKLCH, coords: [l, CHROMA, h], alpha: 1});
+
// Delta metrics the avg/min/max/median stats can report on (keys match getDeltas).
const METRICS = {
error: "Error",
@@ -180,7 +188,7 @@ function buildInspector () {
// Recompute a single patch across every GMA. Calls compute() directly (not
// mapColor) so hovering never adds to the timing stats.
function inspect (l, h) {
- let color = new Color("oklch", [l, CHROMA, h]);
+ let color = oklchColor(l, h);
let oklch = [l, CHROMA, h];
inspector.classList.add("active");
coords.textContent = `oklch(${prec(l, 3)} ${CHROMA} ${h})`;
@@ -188,7 +196,7 @@ function inspect (l, h) {
for (let g of gmas) {
let mapped = g.config.compute(color);
let deltas = getDeltas(color, mapped, oklch, weights);
- g.inspectSwatch.style.background = mapped.to("p3").toString({precision: 3});
+ g.inspectSwatch.style.background = serialize(to(mapped, P3), {precision: 3});
g.inspectErr.textContent = prec(deltas[view.metric], 2);
}
}
@@ -268,19 +276,19 @@ function tick () {
let cells = gmas.map(() => "");
for (let h of hues) {
- let color = new Color("oklch", [l, CHROMA, h]);
+ let color = oklchColor(l, h);
let oklch = [l, CHROMA, h];
let mapped = mapColor(color, representatives); // timed (incl. final P3 clip)
gmas.forEach((g, gi) => {
let mc = mapped[g.id];
- // Deltas are measured outside the timed region.
+ // Deltas and swatch serialization are measured outside the timed region.
let deltas = getDeltas(color, mc, oklch, weights);
for (let m in METRICS) {
g.samples[m][g.n] = deltas[m];
}
g.n++;
- cells[gi] += `
| `;
+ cells[gi] += ` | `;
});
}
diff --git a/gamut-mapping/map.js b/gamut-mapping/map.js
index f537299..99573bb 100644
--- a/gamut-mapping/map.js
+++ b/gamut-mapping/map.js
@@ -1,3 +1,4 @@
+import { to, deltaE, OKLCH } from "colorjs.io/fn";
import methods from "./methods.js";
import stats, { time } from "./stats.js";
@@ -22,14 +23,14 @@ export const defaultWeights = {H: 8, L: 4, C: 1};
* Raw (unrounded) deltas between an input color and one of its gamut-mapped
* results. Takes the input's OKLCh coords so callers mapping one color through
* many methods convert it once rather than per method.
- * @param {import("colorjs.io").default} color - the input color
- * @param {import("colorjs.io").default} mapped - the gamut-mapped color
+ * @param {import("colorjs.io/fn").ColorTypes} color - the input color
+ * @param {import("colorjs.io/fn").ColorTypes} mapped - the gamut-mapped color
* @param {[number, number, number]} oklch - the input color's OKLCh coords [L, C, h]
* @param {{L: number, C: number, H: number}} weights - per-axis Error weights
* @returns {{error: number, E2K: number, EOK: number, L: number, C: number, H: number}}
*/
export function getDeltas (color, mapped, [L1, C1, h1], weights) {
- let [L2, C2, h2] = mapped.to("oklch").coords;
+ let [L2, C2, h2] = to(mapped, OKLCH).coords;
// Raw OKLCh differences. Δh is wrapped to the shortest signed arc, in degrees.
let ΔL = L2 - L1;
@@ -51,8 +52,8 @@ export function getDeltas (color, mapped, [L1, C1, h1], weights) {
return {
error,
- E2K: color.deltaE(mapped, { method: "2000" }),
- EOK: color.deltaE(mapped, { method: "OK" }),
+ E2K: deltaE(color, mapped, { method: "2000" }),
+ EOK: deltaE(color, mapped, { method: "OK" }),
// Signed values for display (direction matters); the consumer compares
// their magnitudes for best/worst highlighting. L in percentage points;
// hue as the signed shortest arc in degrees.
diff --git a/gamut-mapping/methods.js b/gamut-mapping/methods.js
index 9a27f99..53f0313 100644
--- a/gamut-mapping/methods.js
+++ b/gamut-mapping/methods.js
@@ -1,6 +1,15 @@
// Registry of gamut mapping methods. Each method lives in its own file under
// methods/ so their relative sizes are easy to compare. A method is a config
// object with `label`, `description`, and a `compute` function.
+
+// The methods use the procedural `colorjs.io/fn` API and import the color space
+// OBJECTS they need from it, passing those (not string ids) to conversions so
+// the timed work skips registry lookups. This import registers every space in
+// the global registry, still needed for id-based coordinate references like
+// `"oklch.l"` in restoreLH (and as a safety net). It must come first so the
+// spaces exist before any load-time conversion runs (Edge Seeker builds its LUT
+// on import).
+import "colorjs.io/spaces";
import clip, { compute as clipToGamut } from "./methods/clip.js";
import css from "./methods/css.js";
import cssRec2020 from "./methods/css-rec2020.js";
@@ -11,7 +20,8 @@ import raytrace from "./methods/raytrace.js";
import edgeSeeker from "./methods/edge-seeker/index.js";
import hslClip from "./methods/hsl-clip.js";
import scaleGray from "./methods/scale-gray.js";
-import oklchCubic from "./methods/oklch-cubic.js";
+import oklchCubic, { cached as oklchCubicCached } from "./methods/oklch-cubic.js";
+import { to, set, inGamut, OKLCH, P3 } from "colorjs.io/fn";
import { time } from "./stats.js";
const methods = {
@@ -26,6 +36,7 @@ const methods = {
"hsl-clip": hslClip,
"scale-gray": scaleGray,
"oklch-cubic": oklchCubic,
+ "oklch-cubic-cached": oklchCubicCached,
};
// The maximum OkLCh chroma we feed any method, roughly the widest chroma of the
@@ -42,9 +53,16 @@ const MAX_CHROMA = 0.4;
// than an out-of-gamut value the swatch would silently clip.
function normalize (compute) {
return (color) => {
- let input = color.to("oklch").set({ c: c => Math.min(c, MAX_CHROMA) });
+ // Convert to OKLCh and cap chroma at MAX_CHROMA so every method starts from
+ // the same input. `to` hands us a fresh color object to reuse, but we swap
+ // in a fresh coords array rather than capping in place: when the input is
+ // already OKLCh, `to` returns the caller's own coords by reference, so an
+ // in-place cap would corrupt the caller's color.
+ let input = to(color, OKLCH);
+ let [l, c, h] = input.coords;
+ input.coords = [l, Math.min(c, MAX_CHROMA), h];
let result = compute(input);
- return result.inGamut("p3") ? result : clipToGamut(result);
+ return inGamut(result, P3) ? result : clipToGamut(result);
};
}
@@ -56,8 +74,10 @@ function normalize (compute) {
// Restore the original L,H onto a mapped color (chroma kept; out-of-gamut
// results are clipped by normalize downstream).
function restoreLH (mapped, original) {
- let {l, h} = original.to("oklch");
- return mapped.set({"oklch.l": l, "oklch.h": h});
+ let [l, , h] = to(original, OKLCH).coords;
+ // "oklch.l"/"oklch.h" are coordinate references (not space args), resolved by
+ // id via the registry — hence the `colorjs.io/spaces` import above.
+ return set(mapped, {"oklch.l": l, "oklch.h": h});
}
// The color after n converge iterations (n ≥ 2; see the c/p sketch above).
diff --git a/gamut-mapping/methods/bjorn.js b/gamut-mapping/methods/bjorn.js
index 1e4c5d1..b25d4f4 100644
--- a/gamut-mapping/methods/bjorn.js
+++ b/gamut-mapping/methods/bjorn.js
@@ -1,3 +1,6 @@
+import { to, inGamut, OKLab, P3 } from "colorjs.io/fn";
+// util (clamp) and the Okhsl gamut helpers are internal utilities with no
+// dedicated package export, so they come from src/ directly.
import * as util from "colorjs.io/src/util.js";
import { findCusp, findGamutIntersection } from "colorjs.io/src/spaces/okhsl.js";
@@ -34,12 +37,12 @@ const P3Coeff = [
export function compute (color) {
// Approach described in https://bottosson.github.io/posts/gamutclipping/
// For comparison against CSS approaches, constant lightness was used.
- let oklab = color.to("oklab");
+ let oklab = to(color, OKLab); // OKLab coords are [l, a, b]
// Clamp lightness and see if we are in gamut.
- oklab.l = util.clamp(0.0, oklab.l, 1.0); // If doing adaptive lightness, this might not be wanted.
- if (oklab.inGamut("p3", { epsilon: 0 })) {
- return oklab.to("p3");
+ oklab.coords[0] = util.clamp(0.0, oklab.coords[0], 1.0); // If doing adaptive lightness, this might not be wanted.
+ if (inGamut(oklab, P3, { epsilon: 0 })) {
+ return to(oklab, P3);
}
// Get coordinates and calculate chroma
@@ -65,14 +68,14 @@ export function compute (color) {
// Adjust lightness and chroma
if (target !== l) {
- oklab.l = target * (1 - t) + t * l;
+ oklab.coords[0] = target * (1 - t) + t * l;
}
c *= t;
- oklab.a = c * a;
- oklab.b = c * b;
+ oklab.coords[1] = c * a;
+ oklab.coords[2] = c * b;
// Convert back to P3; any residual out-of-gamut is clipped by the registry.
- return oklab.to('p3');
+ return to(oklab, P3);
}
export default {
diff --git a/gamut-mapping/methods/chromium.js b/gamut-mapping/methods/chromium.js
index dd3d390..be3f6b1 100644
--- a/gamut-mapping/methods/chromium.js
+++ b/gamut-mapping/methods/chromium.js
@@ -1,10 +1,12 @@
+import { to, inGamut, OKLab, REC_2020 } from "colorjs.io/fn";
+
export function compute (color) {
// Implementation difference: The reference algorithm does not appear to
// return early for in-gamut colors.
- if (color.inGamut("rec2020")) {
+ if (inGamut(color, REC_2020)) {
return color;
}
- const oklab = color.to("oklab");
+ const oklab = to(color, OKLab);
const [l, a, b] = oklab.coords;
// Constants for the normal vector of the plane formed by white, black, and
// the specified vertex of the gamut.
@@ -116,11 +118,13 @@ export function compute (color) {
}
}
- // Attenuate the ab coordinate by alpha.
+ // Attenuate the ab coordinate by alpha (OKLab coords are [l, a, b]).
// Implementation difference: The reference algorithm does not include a
// final clip, so some resulting colors may be outside of `rec2020`. The
// out-of-gamut result is clipped to P3 by the registry's final step.
- return oklab.set({a: alpha * a, b: alpha * b});
+ oklab.coords[1] = alpha * a;
+ oklab.coords[2] = alpha * b;
+ return oklab;
}
export default {
diff --git a/gamut-mapping/methods/clip.js b/gamut-mapping/methods/clip.js
index bbcc287..2f7ceb8 100644
--- a/gamut-mapping/methods/clip.js
+++ b/gamut-mapping/methods/clip.js
@@ -1,5 +1,8 @@
+import { toGamut, clone, P3 } from "colorjs.io/fn";
+
export function compute (color) {
- return color.clone().toGamut({ space: "p3", method: "clip" });
+ // Clone so we never mutate the shared input; toGamut maps it in place.
+ return toGamut(clone(color), { space: P3, method: "clip" });
}
export default {
diff --git a/gamut-mapping/methods/css-rec2020.js b/gamut-mapping/methods/css-rec2020.js
index 8098dde..f4532ff 100644
--- a/gamut-mapping/methods/css-rec2020.js
+++ b/gamut-mapping/methods/css-rec2020.js
@@ -1,8 +1,8 @@
+import { toGamut, clone, REC_2020 } from "colorjs.io/fn";
+
export function compute (color) {
// CSS-map into rec2020; the out-of-gamut result is clipped to P3 by the registry.
- return color
- .clone()
- .toGamut({ space: "rec2020", method: "css" });
+ return toGamut(clone(color), { space: REC_2020, method: "css" });
}
export default {
diff --git a/gamut-mapping/methods/css.js b/gamut-mapping/methods/css.js
index 927dc8e..80b22aa 100644
--- a/gamut-mapping/methods/css.js
+++ b/gamut-mapping/methods/css.js
@@ -1,5 +1,7 @@
+import { toGamut, clone, P3 } from "colorjs.io/fn";
+
export function compute (color) {
- return color.clone().toGamut({ space: "p3", method: "css" });
+ return toGamut(clone(color), { space: P3, method: "css" });
}
export default {
diff --git a/gamut-mapping/methods/edge-seeker/index.js b/gamut-mapping/methods/edge-seeker/index.js
index afb62a8..0eff08b 100644
--- a/gamut-mapping/methods/edge-seeker/index.js
+++ b/gamut-mapping/methods/edge-seeker/index.js
@@ -1,27 +1,32 @@
-import Color from "colorjs.io";
+import { to, P3, OKLCH } from "colorjs.io/fn";
import { makeEdgeSeeker } from "./makeEdgeSeeker.js";
// Make a function to get the maximum chroma for a given lightness and hue
// Lookup table is created once and reused
const p3EdgeSeeker = makeEdgeSeeker((r, g, b) => {
- const [l, c, h = 0] = new Color("p3", [r, g, b]).to("oklch").coords;
+ const [l, c, h = 0] = to({ space: P3, coords: [r, g, b] }, OKLCH).coords;
return { l, c, h };
});
export function compute (color) {
- let [l, c, h] = color.to("oklch").coords;
+ // `to` gives us a fresh OKLCh color object we can reduce in place.
+ let result = to(color, OKLCH);
+ let [l, c] = result.coords;
if (l <= 0) {
- return new Color("oklch", [0, 0, h]);
+ result.coords[0] = result.coords[1] = 0; // black, hue preserved
+ return result;
}
if (l >= 1) {
- return new Color("oklch", [1, 0, h]);
+ result.coords[0] = 1;
+ result.coords[1] = 0; // white, hue preserved
+ return result;
}
- let maxChroma = p3EdgeSeeker(l, h || 0);
+ let maxChroma = p3EdgeSeeker(l, result.coords[2] || 0);
if (c > maxChroma) {
- c = maxChroma;
+ // Any residual out-of-gamut from the LUT approximation is clipped by the registry.
+ result.coords[1] = maxChroma;
}
- // Any residual out-of-gamut from the LUT approximation is clipped by the registry.
- return new Color("oklch", [l, c, h]);
+ return result;
}
export default {
diff --git a/gamut-mapping/methods/hsl-clip.js b/gamut-mapping/methods/hsl-clip.js
index 5e4ba4f..65437d1 100644
--- a/gamut-mapping/methods/hsl-clip.js
+++ b/gamut-mapping/methods/hsl-clip.js
@@ -1,18 +1,12 @@
-import Color from "colorjs.io";
-
-// Use the ColorSpace class and built-in spaces from the same colorjs.io instance
-// that Color uses, so hsl-p3 is registered in the registry that color.to() queries.
-const ColorSpace = Color.Space;
-const HSL = ColorSpace.get("hsl");
-const P3 = ColorSpace.get("p3");
+import { to, HSL_P3, OKLCH } from "colorjs.io/fn";
// One atomic clip: clamp HSL-P3 saturation into [0, 100] and return. Iterating
// this and restoring the original L,H between steps is the converge harness's
// job (see methods.js), so the method itself stays a single operation.
export function compute (color) {
- let hsl = color.to("hsl-p3");
+ let hsl = to(color, HSL_P3);
hsl.coords[1] = Math.max(0, Math.min(hsl.coords[1], 100));
- return hsl.to("oklch");
+ return to(hsl, OKLCH);
}
export default {
diff --git a/gamut-mapping/methods/oklch-cubic.js b/gamut-mapping/methods/oklch-cubic.js
index 9d5baf3..387f873 100644
--- a/gamut-mapping/methods/oklch-cubic.js
+++ b/gamut-mapping/methods/oklch-cubic.js
@@ -1,9 +1,11 @@
-import { multiplyMatrices, multiply_v3_m3x3 } from "colorjs.io/src/util.js";
-import oklab from "colorjs.io/src/spaces/oklab.js";
-import p3linear from "colorjs.io/src/spaces/p3-linear.js";
+import { to, OKLCH, OKLab, P3_Linear } from "colorjs.io/fn";
+// multiplyMatrices/multiply_v3_m3x3 are math utilities with no dedicated package
+// export, so they come from src/. The space objects (for their `.M` matrices)
+// come from the dedicated `colorjs.io/fn` export.
+import { multiplyMatrices, multiply_v3_m3x3, toPrecision } from "colorjs.io/src/util.js";
-const oklabToLMS = oklab.M.LabtoLMS; // OKLab → LMS'
-const lmsToRGB = multiplyMatrices(p3linear.M.fromXYZ, oklab.M.LMStoXYZ); // LMS³ → linear P3
+const oklabToLMS = OKLab.M.LabtoLMS; // OKLab → LMS'
+const lmsToRGB = multiplyMatrices(P3_Linear.M.fromXYZ, OKLab.M.LMStoXYZ); // LMS³ → linear P3
// Smallest real root of a·t³ + b·t² + c·t + d in the open interval (lo, hi), or
// Infinity if none. Closed form (no iteration); returns a scalar, not an array,
@@ -115,60 +117,92 @@ function getHueData (H) {
return {A, B, D, tLower, turn};
}
-export function compute (color) {
- color = color.to("oklch");
- let [L, C, H] = color.coords;
-
- // Return early for achromatic colors or white/black
- let isBlack = L <= 0;
- let isWhite = L >= 1;
- let isGray = C <= 0 || C === null;
+// Build the chroma-reduction compute() around a hue-data source. The cubic
+// solving is identical whether the per-hue structure is recomputed every call or
+// memoized; that choice is the only thing the two variants differ by, so it's
+// injected here rather than duplicated.
+function makeCompute (hueData) {
+ return function compute (color) {
+ color = to(color, OKLCH);
+ let [L, C, H] = color.coords;
+
+ // Return early for achromatic colors or white/black
+ let isBlack = L <= 0;
+ let isWhite = L >= 1;
+ let isGray = C <= 0 || C === null;
+
+ if (isBlack || isWhite || isGray) {
+ if (isBlack) {
+ color.coords[0] = 0;
+ }
+ else if (isWhite) {
+ color.coords[0] = 1;
+ }
- if (isBlack || isWhite || isGray) {
- if (isBlack) {
- color.coords[0] = 0;
+ color.coords[1] = 0;
+ return color;
}
- else if (isWhite) {
- color.coords[0] = 1;
+
+ let {A, B, D, tLower, turn} = hueData(H);
+
+ // Work in t = c/L. The cap starts at the input chroma and the (hue-only) lower
+ // exit; the white bound below can only pull it lower.
+ let maxT = Math.min(C / L, tLower);
+
+ // White exit: the smallest t > 0 at which any channel reaches 1, i.e.
+ // Pᵢ(t) = L⁻³. Same cubic as Pᵢ, only the constant shifts to 1 − L⁻³. This is
+ // the one part that depends on L, so it's the only per-color solving left.
+ let target = 1 / L ** 3; // Pᵢ value at the white bound
+ let d = 1 - target; // constant term of Pᵢ(t) − L⁻³
+ for (let i = 0; i < 3; i++) {
+ // Monotonic up to the running cap (no turning point before it)? Then it can
+ // only reach the white bound if it's rising and not still below it at maxT —
+ // otherwise skip the solve. (Its black bound, if any, is already in tLower.)
+ if (turn[i] > maxT) {
+ if (A[i] <= 0) {
+ continue;
+ }
+ let PmaxT = ((D[i] * maxT + 3 * B[i]) * maxT + 3 * A[i]) * maxT + 1;
+ if (PmaxT < target) {
+ continue;
+ }
+ }
+ maxT = Math.min(maxT, firstRoot(D[i], 3 * B[i], 3 * A[i], d, 1e-9, maxT));
}
- color.coords[1] = 0;
+ color.coords[1] = L * maxT; // replace input chroma with the reduced value
return color;
- }
-
- let {A, B, D, tLower, turn} = getHueData(H);
-
- // Work in t = c/L. The cap starts at the input chroma and the (hue-only) lower
- // exit; the white bound below can only pull it lower.
- let maxT = Math.min(C / L, tLower);
+ };
+}
- // White exit: the smallest t > 0 at which any channel reaches 1, i.e.
- // Pᵢ(t) = L⁻³. Same cubic as Pᵢ, only the constant shifts to 1 − L⁻³. This is
- // the one part that depends on L, so it's the only per-color solving left.
- let target = 1 / L ** 3; // Pᵢ value at the white bound
- let d = 1 - target; // constant term of Pᵢ(t) − L⁻³
- for (let i = 0; i < 3; i++) {
- // Monotonic up to the running cap (no turning point before it)? Then it can
- // only reach the white bound if it's rising and not still below it at maxT —
- // otherwise skip the solve. (Its black bound, if any, is already in tLower.)
- if (turn[i] > maxT) {
- if (A[i] <= 0) {
- continue;
- }
- let PmaxT = ((D[i] * maxT + 3 * B[i]) * maxT + 3 * A[i]) * maxT + 1;
- if (PmaxT < target) {
- continue;
- }
- }
- maxT = Math.min(maxT, firstRoot(D[i], 3 * B[i], 3 * A[i], d, 1e-9, maxT));
+// No-cache: recompute the per-hue structure on every call.
+export const compute = makeCompute(getHueData);
+
+// Cached: the per-hue structure is fully determined by H, so memoize it — a
+// sweep over many lightnesses at one hue then pays the setup once. The key is
+// quantized to 4 significant digits so arbitrary float hues can't grow the Map
+// unbounded; that collapses hues within ~0.1° onto a shared (still exact-at-that-
+// hue) structure.
+let hueCache = new Map();
+function cachedHueData (H) {
+ let key = toPrecision(H, 4);
+ let data = hueCache.get(key);
+ if (data === undefined) {
+ data = getHueData(key);
+ hueCache.set(key, data);
}
-
- color.coords[1] = L * maxT; // replace input chroma with the reduced value
- return color;
+ return data;
}
+export const cachedCompute = makeCompute(cachedHueData);
export default {
label: "OKLCh cubic",
description: "Reduce OKLCh chroma to the exact P3 gamut boundary by solving, in closed form, the cubic that each linear-P3 channel traces as a function of chroma.",
compute,
};
+
+export const cached = {
+ label: "OKLCh cubic (cached)",
+ description: "Like OKLCh cubic, but caches the per-hue structure (keyed by hue to 4 significant digits) so a sweep over many lightnesses at one hue solves it once.",
+ compute: cachedCompute,
+};
diff --git a/gamut-mapping/methods/raytrace.js b/gamut-mapping/methods/raytrace.js
index 1b94d1c..1833c21 100644
--- a/gamut-mapping/methods/raytrace.js
+++ b/gamut-mapping/methods/raytrace.js
@@ -1,4 +1,6 @@
-import Color from "colorjs.io";
+import { to, inGamut, setAll, P3, P3_Linear, OKLCH, XYZ_D65 } from "colorjs.io/fn";
+// WHITES (white points), util, and the angle helper are internal utilities with
+// no dedicated package export, so they come from src/ directly.
import { WHITES } from "colorjs.io/src/adapt.js";
import * as util from "colorjs.io/src/util.js";
import { constrain as constrainAngle } from "colorjs.io/src/angles.js";
@@ -194,8 +196,9 @@ function trace (orig) {
}
// Remove noise from floating point math by clipping
- orig.setAll(
- 'p3-linear',
+ setAll(
+ orig,
+ P3_Linear,
[
util.clamp(0.0, mapColor[0], 1.0),
util.clamp(0.0, mapColor[1], 1.0),
@@ -203,24 +206,24 @@ function trace (orig) {
]
);
- return orig.to("p3");
+ return to(orig, P3);
}
export function compute (color) {
// An approached originally designed for ColorAide.
// https://facelessuser.github.io/coloraide/gamut/#ray-tracing-chroma-reduction
- if (color.inGamut("p3", { epsilon: 0 })) {
- return color.to("p3");
+ if (inGamut(color, P3, { epsilon: 0 })) {
+ return to(color, P3);
}
- let mapColor = color.to("oklch");
+ let mapColor = to(color, OKLCH);
let lightness = mapColor.coords[0];
if (lightness >= 1) {
- return new Color({ space: "xyz-d65", coords: WHITES["D65"] }).to("p3");
+ return to({ space: XYZ_D65, coords: WHITES["D65"] }, P3);
}
else if (lightness <= 0) {
- return new Color({ space: "xyz-d65", coords: [0, 0, 0] }).to("p3");
+ return to({ space: XYZ_D65, coords: [0, 0, 0] }, P3);
}
return trace(mapColor);
}
diff --git a/gamut-mapping/methods/scale-gray.js b/gamut-mapping/methods/scale-gray.js
index 514c8d2..f0838ae 100644
--- a/gamut-mapping/methods/scale-gray.js
+++ b/gamut-mapping/methods/scale-gray.js
@@ -1,4 +1,4 @@
-import Color from "colorjs.io";
+import { to, P3, P3_Linear, OKLCH } from "colorjs.io/fn";
function progress (n, min, max) {
return (n - min) / (max - min);
@@ -13,10 +13,11 @@ function clamp (min, n, max) {
}
export function compute (color) {
- let p3 = color.to("p3-linear").coords;
- let lch = color.to("oklch").coords;
+ let plinear = to(color, P3_Linear);
+ let p3 = plinear.coords;
+ let lch = to(color, OKLCH).coords;
// Gray with the same L has equal linear-P3 coords; use that value as the midpoint.
- let midpoint = new Color("oklch", [lch[0], 0, 0]).to("p3-linear").coords[0];
+ let midpoint = to({ space: OKLCH, coords: [lch[0], 0, 0] }, P3_Linear).coords[0];
// For each out-of-gamut channel, the fraction (0–1) we must lerp it toward the
// midpoint to pull it back to the boundary. In-gamut channels naturally yield 0
@@ -26,9 +27,10 @@ export function compute (color) {
// colors (all channels on the midpoint) back in.
let maxP = Math.max(0, ...p3.map(c => c === midpoint ? 0 : progress(clamp(0, c, 1), c, midpoint)));
- let scaledCoords = p3.map(c => lerp(maxP, c, midpoint));
+ // Reuse the same linear-P3 color for the result.
+ plinear.coords = p3.map(c => lerp(maxP, c, midpoint));
- return new Color("p3-linear", scaledCoords).to("p3");
+ return to(plinear, P3);
}
export default {
diff --git a/gamut-mapping/methods/scale.js b/gamut-mapping/methods/scale.js
index 6fbf1be..7e85800 100644
--- a/gamut-mapping/methods/scale.js
+++ b/gamut-mapping/methods/scale.js
@@ -1,18 +1,18 @@
-import Color from "colorjs.io";
+import { to, P3, P3_Linear } from "colorjs.io/fn";
export function compute (color) {
+ let plinear = to(color, P3_Linear);
+
// Make in gamut range symmetrical around 0 [-0.5, 0.5] instead of [0, 1]
- let deltas = color.to("p3-linear").coords.map(c => c - .5);
+ let deltas = plinear.coords.map(c => c - .5);
let maxDistance = Math.max(...deltas.map(c => Math.abs(c)));
let scalingFactor = maxDistance / .5;
- let scaledCoords = deltas.map((delta, i) => {
- let scaled = delta / scalingFactor;
- return scaled + .5;
- });
+ // Scale every channel back into [0, 1]; reuse the same color for the P3 conversion.
+ plinear.coords = deltas.map(delta => delta / scalingFactor + .5);
- return new Color("p3-linear", scaledCoords).to("p3");
+ return to(plinear, P3);
}
export default {