colorjs.io
Version:
Let’s get serious about color
303 lines (257 loc) • 8.51 kB
JavaScript
/**
* Functions related to color interpolation
*/
import ColorSpace from "./ColorSpace.js";
import { type, interpolate, isNone } from "./util.js";
import getColor from "./getColor.js";
import clone from "./clone.js";
import to from "./to.js";
import toGamut from "./toGamut.js";
import get from "./get.js";
import set from "./set.js";
import defaults from "./defaults.js";
import * as angles from "./angles.js";
import deltaE from "./deltaE.js";
/** @import { ColorTypes, PlainColorObject, Ref } from "./types.js" */
// Type re-exports
/** @typedef {import("./types.js").MixOptions} MixOptions */
/** @typedef {import("./types.js").Range} Range */
/** @typedef {import("./types.js").RangeOptions} RangeOptions */
/** @typedef {import("./types.js").StepsOptions} StepsOptions */
/**
* Return an intermediate color between two colors
* @overload
* @param {ColorTypes} c1
* @param {ColorTypes} c2
* @param {MixOptions} [options]
* @returns {PlainColorObject}
*/
/**
* @overload
* @param {ColorTypes} c1
* @param {ColorTypes} c2
* @param {number} [p=0.5]
* @param {MixOptions} [options]
* @returns {PlainColorObject}
*/
export function mix (c1, c2, p, o = {}) {
[c1, c2] = [getColor(c1), getColor(c2)];
if (type(p) === "object") {
[p, o] = [0.5, p];
}
let r = range(c1, c2, o);
return r(p ?? 0.5); // why not give p a default value like we do for options? Overloading doesn't work, and TS complains
}
/**
* Get an array of discrete steps
* @overload
* @param {ColorTypes} c1
* @param {ColorTypes} c2
* @param {StepsOptions} [options]
* @returns {PlainColorObject[]}
*/
/**
* @overload
* @param {Range} range
* @param {StepsOptions} [options]
* @returns {PlainColorObject[]}
*/
export function steps (c1, c2, options = {}) {
let colorRange;
if (isRange(c1)) {
// Tweaking existing range
[colorRange, options] = [c1, c2];
[c1, c2] = colorRange.rangeArgs.colors;
}
let { maxDeltaE, deltaEMethod, steps = 2, maxSteps = 1000, ...rangeOptions } = options;
if (!colorRange) {
[c1, c2] = [getColor(c1), getColor(c2)];
colorRange = range(c1, c2, rangeOptions);
}
let totalDelta = deltaE(c1, c2);
let actualSteps =
maxDeltaE > 0 ? Math.max(steps, Math.ceil(totalDelta / maxDeltaE) + 1) : steps;
let ret = [];
if (maxSteps !== undefined) {
actualSteps = Math.min(actualSteps, maxSteps);
}
if (actualSteps === 1) {
ret = [{ p: 0.5, color: colorRange(0.5) }];
}
else {
let step = 1 / (actualSteps - 1);
ret = Array.from({ length: actualSteps }, (_, i) => {
let p = i * step;
return { p, color: colorRange(p) };
});
}
if (maxDeltaE > 0) {
// Iterate over all stops and find max deltaE
let maxDelta = ret.reduce((acc, cur, i) => {
if (i === 0) {
return 0;
}
let ΔΕ = deltaE(cur.color, ret[i - 1].color, deltaEMethod);
return Math.max(acc, ΔΕ);
}, 0);
while (maxDelta > maxDeltaE) {
// Insert intermediate stops and measure maxDelta again
// We need to do this for all pairs, otherwise the midpoint shifts
maxDelta = 0;
for (let i = 1; i < ret.length && ret.length < maxSteps; i++) {
let prev = ret[i - 1];
let cur = ret[i];
let p = (cur.p + prev.p) / 2;
let color = colorRange(p);
maxDelta = Math.max(maxDelta, deltaE(color, prev.color), deltaE(color, cur.color));
ret.splice(i, 0, { p, color: colorRange(p) });
i++;
}
}
}
ret = ret.map(a => a.color);
return ret;
}
/**
* Creates a function that accepts a number and returns a color.
* For numbers in the range 0 to 1, the function interpolates;
* for numbers outside that range, the function extrapolates
* (and thus may not return the results you expect)
* @overload
* @param {Range} range
* @param {RangeOptions} [options]
* @returns {Range}
*/
/**
* @overload
* @param {ColorTypes} color1
* @param {ColorTypes} color2
* @param {RangeOptions & Record<string, any>} [options]
* @returns {Range}
*/
export function range (color1, color2, options = {}) {
if (isRange(color1)) {
// Tweaking existing range
let [r, options] = [color1, color2];
return range(...r.rangeArgs.colors, { ...r.rangeArgs.options, ...options });
}
let { space, outputSpace, progression, premultiplied } = options;
color1 = getColor(color1);
color2 = getColor(color2);
// Make sure we're working on copies of these colors
color1 = clone(color1);
color2 = clone(color2);
let rangeArgs = { colors: [color1, color2], options };
if (space) {
space = ColorSpace.get(space);
}
else {
space = ColorSpace.registry[defaults.interpolationSpace] || color1.space;
}
outputSpace = outputSpace ? ColorSpace.get(outputSpace) : space;
color1 = to(color1, space);
color2 = to(color2, space);
// Gamut map to avoid areas of flat color
color1 = toGamut(color1);
color2 = toGamut(color2);
// The hue coordinate is not always called "h" (e.g. it's "hz" in Jzczhz)
let hueId = space.hueId;
let hueIndex = space.hueIndex;
// Handle hue interpolation
// See https://github.com/w3c/csswg-drafts/issues/4735#issuecomment-635741840
if (hueId) {
let arc = (options.hue = options.hue || "shorter");
let /** @type {Ref} */ hue = [space, hueId];
let [θ1, θ2] = [get(color1, hue), get(color2, hue)];
// Undefined hues must be evaluated before hue fix-up to properly
// calculate hue arcs between undefined and defined hues.
// See https://github.com/w3c/csswg-drafts/issues/9436#issuecomment-1746957545
if (isNone(θ1) && !isNone(θ2)) {
θ1 = θ2;
}
else if (isNone(θ2) && !isNone(θ1)) {
θ2 = θ1;
}
[θ1, θ2] = angles.adjust(arc, [θ1, θ2]);
set(color1, hue, θ1);
set(color2, hue, θ2);
}
// An undefined alpha takes the other color's alpha, and premultiplication must use
// that carried forward value, rather than the zero it would otherwise be treated as.
// If both alphas are undefined, they stay that way, and no premultiplication happens.
// See https://drafts.csswg.org/css-color-4/#interpolation-missing
if (isNone(color1.alpha) && !isNone(color2.alpha)) {
color1.alpha = color2.alpha;
}
else if (isNone(color2.alpha) && !isNone(color1.alpha)) {
color2.alpha = color1.alpha;
}
if (premultiplied) {
color1.coords = premultiply(color1.coords, color1.alpha, hueIndex);
color2.coords = premultiply(color2.coords, color2.alpha, hueIndex);
}
return Object.assign(
p => {
p = progression ? progression(p) : p;
let coords = color1.coords.map((start, i) => {
let end = color2.coords[i];
return interpolate(start, end, p);
});
let alpha = interpolate(color1.alpha, color2.alpha, p);
let ret = { space, coords, alpha };
if (premultiplied) {
// Undo premultiplication, again leaving any hue angle untouched
ret.coords = premultiply(ret.coords, alpha, hueIndex, true);
}
if (outputSpace !== space) {
ret = to(ret, outputSpace);
}
return ret;
},
{
rangeArgs,
},
);
}
/**
* Premultiply a color's coordinates by its alpha, or undo premultiplication by dividing by it.
* Per CSS Color 4, the hue angle of a polar space is never premultiplied,
* `none` components stay `none`, a `none` alpha means no (un)premultiplication happens at all,
* and un-premultiplying by a zero alpha leaves the coords as they are, rather than dividing by zero.
* See https://drafts.csswg.org/css-color-4/#interpolation-alpha
* @param {(number | null)[]} coords
* @param {number | null} alpha
* @param {number} hueIndex - Index of the hue coordinate, or -1 if the space has none
* @param {boolean} [undo=false] - Divide by alpha instead of multiplying by it
* @returns {[number, number, number]}
*/
function premultiply (coords, alpha, hueIndex, undo = false) {
if (isNone(alpha) || (undo && alpha === 0)) {
// The (un-)premultiplied value is the value itself
return /** @type {[number, number, number]} */ (coords);
}
return /** @type {[number, number, number]} */ (
coords.map((c, i) => {
if (i === hueIndex || isNone(c)) {
return c;
}
return undo ? c / alpha : c * alpha;
})
);
}
/**
* @param {any} val
* @returns {val is Range}
*/
export function isRange (val) {
return type(val) === "function" && !!val.rangeArgs;
}
defaults.interpolationSpace = "lab";
/**
* @param {typeof import("./color.js").default} Color
*/
export function register (Color) {
Color.defineFunction("mix", mix, { returns: "color" });
Color.defineFunction("range", range, { returns: "function<color>" });
Color.defineFunction("steps", steps, { returns: "array<color>" });
}