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 {