plotly.js
Version:
The open source javascript graphing library that powers plotly
466 lines (416 loc) • 15.9 kB
JavaScript
'use strict';
/**
* Color specifier handling for the whole library.
*
* Every function here that returns a color resolves a specifier it cannot parse
* to opaque black, and warns. That includes a specifier that is missing, isn't
* a string, or is not valid CSS. No function throws, and none returns null, so
* callers never have to guard before drawing.
*/
// TODO: Import functions from `culori/fn` when converting to ESM to allow for tree-shaking
const {
converter,
formatHex: culoriFormatHex,
formatRgb: culoriFormatRgb,
wcagContrast: culoriWcagContrast,
wcagLuminance
} = require('culori');
const { isArrayOrTypedArray } = require('../../lib/array');
const { warn } = require('../../lib/loggers');
const { background, defaultLine, defaults, lightLine } = require('./attributes');
const toRgb = converter('rgb');
const toHsl = converter('hsl');
// `toRgb` for callers that may hand over something other than a color string.
// Returns undefined for anything it cannot parse (the same as the converters).
const toColor = (cstr) => {
if (typeof cstr !== 'string') return undefined;
// Switch to lowercase because culori has a bug where it can't handle uppercase
// for some valid values (`RGB(1,2,3)`, `hsl(0DEG 100% 50%)`, etc.)
const s = cstr.trim().toLowerCase();
// Disallow hex colors without # to match CSS color spec (culori allows them)
// No CSS named color is spelled only in hex digits.
if (/^[0-9a-f]+$/.test(s)) return undefined;
// An unknown unit throws rather than returning undefined, as in `rgb(0px 1 2)`
try {
return toRgb(s);
} catch (e) {
return undefined;
}
};
// Clamp a 0-1 channel to gamut. culori returns out-of-range values for wide-gamut
// inputs. Also maps undefined/NaN to 0 and Infinity to 1, matching the browser.
// `Lib.constrain` does not substitute here: it is Math.max/Math.min, so a NaN
// stays a NaN and reaches the WebGL buffers.
const clamp01 = (v) => (v > 0 ? (v > 1 ? 1 : v) : 0);
const BLACK = { mode: 'rgb', r: 0, g: 0, b: 0, alpha: 1 };
// Arithmetic on channels lands on values such as 0.8333333333333333, which is
// 212.49999999999997 in 8 bits. The culori formatters round that down to 212,
// one less than the intended 213. Round each channel to six decimals of an
// 8-bit step first. Six decimals absorb the float error and still leave a
// genuine value such as 122.45 alone.
const snap01 = (v) => Math.round(v * 255e6) / 255e6;
const snap = (c) => ({ ...c, r: snap01(c.r), g: snap01(c.g), b: snap01(c.b) });
const formatRgb = (c) => culoriFormatRgb(snap(c));
const formatHex = (c) => culoriFormatHex(snap(c));
/**
* Parse a color specifier string and return it as a culori rgb color object.
*
* @param {*} cstr - Color specifier
* @param {Boolean} [silent] - Skip the warning, for callers that run per data point
* @return {Object} A culori rgb color ({ mode: 'rgb', r: _, g: _, b: _, alpha: _ })
*/
const parse = (cstr, silent) => {
const c = toColor(cstr);
if (!c) {
if (!silent && cstr != null) warn(`Invalid color specifier: "${cstr}". Defaulting to "#000"`);
return BLACK;
}
// `toRgb` omits alpha when it's 1; make sure it's added since we expect it
c.alpha ??= 1;
return c;
};
// TODO: rename to `rgbString` to better describe return value
/**
* Convert any color specifier to a normalized `rgb(r, g, b)` string.
* Force alpha to 1 so that it gets dropped in the result.
*
* @param {*} cstr - Color specifier
* @return {String}
*/
const rgb = (cstr) => formatRgb({ ...parse(cstr), alpha: 1 });
/**
* Return the alpha channel of a color (0 if falsy).
*
* @param {*} cstr - Color specifier
* @return {Number} Alpha value in the range [0, 1]
*/
const opacity = (cstr) => (cstr ? parse(cstr).alpha : 0);
/**
* Test whether a value holds raw color channels rather than a color specifier.
*
* Per-point colors in the WebGL paths arrive either way, as a plain array or as
* a typed array. `normalize` accepts both; `isValid` accepts neither, since a
* channel array is not a parseable string.
*
* @param {*} v - Value to test, of any type
* @return {Boolean} `true` for 3 or more finite numbers
*/
const isChannelArray = (v) => {
return (
isArrayOrTypedArray(v) &&
v.length > 2 &&
Number.isFinite(v[0]) &&
Number.isFinite(v[1]) &&
Number.isFinite(v[2])
);
};
// A channel above 1 means the array holds 0-255 values, otherwise it already
// holds 0-1 values. Alpha gets its own test because the two scales could be mixed.
const channelsToRgb = (v) => {
const [r, g, b, alpha] = v;
const scale = Math.max(r, g, b) > 1 ? 1 / 255 : 1;
const a = alpha ?? 1;
return { mode: 'rgb', r: r * scale, g: g * scale, b: b * scale, alpha: a > 1 ? a / 255 : a };
};
/**
* Convert a color specifier to a 4-element `[r, g, b, a]` representation.
*
* `input` can also be a channel array, which the WebGL paths pass per data
* point. An `r`, `g`, or `b` above 1 puts those three on a 0-255 scale. Alpha
* scales on its own. Unlike the rest of this module, `normalize` never warns.
*
* @param {*} input - Color specifier, or an `[r, g, b]` / `[r, g, b, a]` channel array
* @param {'uint8'|'uint8_clamped'|'float32'|'float64'} [type] - Omit for a plain
* array in [0, 1]. `'uint8'` and `'uint8_clamped'` return a `Uint8Array` in
* [0, 255]. `'float32'` and `'float64'` return a typed array in [0, 1].
* @return {Number[]|Uint8Array}
*/
const normalize = (input, type) => {
const c = isChannelArray(input) ? channelsToRgb(input) : parse(input, true);
const v = [clamp01(c.r), clamp01(c.g), clamp01(c.b), clamp01(c.alpha)];
if (type === 'uint8' || type === 'uint8_clamped') return Uint8Array.from(v, (x) => Math.round(x * 255));
if (type === 'float32') return Float32Array.from(v);
if (type === 'float64') return Float64Array.from(v);
return v;
};
// TODO: rename to `setOpacity`, since it replaces the alpha rather than adding to it
/**
* Replace a color's alpha channel with `op`.
*
* @param {*} cstr - Color specifier
* @param {Number} op - Opacity. Will be clamped to the range [0, 1].
* @return {String} `rgb(...)` when the result is opaque, `rgba(...)` otherwise
*/
const addOpacity = (cstr, op) => formatRgb({ ...parse(cstr), alpha: clamp01(op) });
/**
* Combine two colors into one apparent color by compositing `front` over `back`.
* If `back` is missing, the module `background` is assumed behind it.
*
* A translucent or transparent `back` is flattened against white, so a transparent
* paper_bgcolor is treated as a white page. Opaque backs are exact.
*
* @param {*} front - Foreground color specifier
* @param {*} back - Background color specifier
* @return {String} Resulting `rgb(...)` string
*/
const combine = (front, back) => {
const fc = parse(front);
const fa = fc.alpha;
if (fa === 1) return formatRgb(fc);
const bc = parse(back || background);
const ba = bc.alpha;
const over = (f, b) => (ba === 1 ? b : 1 - ba + b * ba) * (1 - fa) + f * fa;
return formatRgb({ mode: 'rgb', r: over(fc.r, bc.r), g: over(fc.g, bc.g), b: over(fc.b, bc.b) });
};
/**
* Linearly interpolate between two colors at a normalized position (0 to 1).
* Ignores alpha; result is `factor * first + (1 - factor) * second`.
*
* @param {*} first - Color specifier
* @param {*} second - Color specifier
* @param {Number} factor - Interpolation position in [0, 1]
* @return {String} Resulting `rgb(...)` string
*/
const interpolate = (first, second, factor) => {
const fc = parse(first);
const sc = parse(second);
const lerp = (a, b) => factor * a + (1 - factor) * b;
return formatRgb({ mode: 'rgb', r: lerp(fc.r, sc.r), g: lerp(fc.g, sc.g), b: lerp(fc.b, sc.b) });
};
/**
* Shift a color's HSL lightness additively by `delta` percentage points.
* Positive delta = lighter, negative = darker. Alpha is preserved.
*
* @param {*} cstr - Color specifier
* @param {Number} delta - Lightness shift in HSL percentage points
* @return {String} Resulting `rgb(...)` string
*/
const adjustLightness = (cstr, delta) => {
const c = parse(cstr);
const h = toHsl(c) || { mode: 'hsl', h: 0, s: 0, l: 0 };
return formatRgb(toRgb({ ...h, l: clamp01((h.l * 100 + delta) / 100), alpha: c.alpha }));
};
/**
* WCAG contrast ratio between two colors, in [1, 21].
*
* @param {*} cstr1 - Color specifier
* @param {*} cstr2 - Color specifier
* @return {Number}
*/
const wcagContrast = (cstr1, cstr2) => culoriWcagContrast(parse(cstr1), parse(cstr2));
/**
* Test whether a color reads as dark.
*
* Compares the contrast ratio against `background` with the contrast ratio
* against `defaultLine`. A dark color contrasts better with `background`, so
* `contrast` returns the more legible of the two whenever the caller supplies
* no lighten or darken amount.
*
* @param {*} cstr - Color specifier
* @return {Boolean}
*/
const isDark = (cstr) => wcagContrast(cstr, background) > wcagContrast(cstr, defaultLine);
// The two colors `contrast` falls back to, as rgb strings. This is needed because the
// attributes could be hex and callers expect `rgb(...)`.
const backgroundRgb = formatRgb(parse(background));
const defaultLineRgb = formatRgb(parse(defaultLine));
/**
* Create a color that contrasts with `cstr`: dark colors are lightened,
* light colors are darkened. Without `lightAmount` / `darkAmount` the
* result goes all the way to the background or defaultLine.
*
* @param {*} cstr - Color specifier
* @param {Number} [lightAmount] - Lighten percentage when cstr is dark
* @param {Number} [darkAmount] - Darken percentage when cstr is light
* @return {String} Resulting `rgb(...)` string
*/
const contrast = (cstr, lightAmount, darkAmount) => {
if (parse(cstr).alpha !== 1) cstr = combine(cstr, background);
if (isDark(cstr)) {
return lightAmount ? adjustLightness(cstr, lightAmount) : backgroundRgb;
} else {
return darkAmount ? adjustLightness(cstr, -darkAmount) : defaultLineRgb;
}
};
/**
* Apply `stroke` and `stroke-opacity` styles to a D3 selection.
*
* @param {Selection} s - D3 selection
* @param {*} cstr - Color specifier
*/
const stroke = (s, cstr) => {
s.style({ stroke: rgb(cstr), 'stroke-opacity': parse(cstr).alpha });
};
/**
* Apply `fill` and `fill-opacity` styles to a D3 selection.
*
* @param {Selection} s - D3 selection
* @param {*} cstr - Color specifier
*/
const fill = (s, cstr) => {
s.style({ fill: rgb(cstr), 'fill-opacity': parse(cstr).alpha });
};
/**
* Test whether two color specifiers resolve to the same `rgb(...)` string.
*
* @param {*} cstr1 - Color specifier
* @param {*} cstr2 - Color specifier
* @return {Boolean}
*/
const equals = (cstr1, cstr2) => !!(cstr1 && cstr2 && rgb(cstr1) === rgb(cstr2));
/**
* Test whether a string is a valid color specifier (does not throw).
*
* This is the only way to tell a color the module could not parse from one that
* is genuinely black, since every other function resolves both the same way.
*
* @param {*} cstr - Color specifier
* @return {Boolean} `false` for anything that is not a parseable color string
*/
const isValid = (cstr) => toColor(cstr) !== undefined;
/**
* Brighten a color by adding a fixed amount to each RGB channel.
* Unlike `adjustLightness`, this works in RGB space, not HSL. Alpha is preserved.
*
* @param {*} cstr - Color specifier
* @param {Number} [amount=10] - Percent in [-100, 100]
* @return {String} Resulting `rgb(...)` / `rgba(...)` string
*/
const brighten = (cstr, amount) => {
amount = amount === 0 ? 0 : amount || 10;
const c = parse(cstr);
const adj = amount / 100;
return formatRgb({
...c,
r: clamp01(c.r + adj),
g: clamp01(c.g + adj),
b: clamp01(c.b + adj)
});
};
/**
* Mix two colors by `weight` percent (0 = all `cstr1`, 100 = all `cstr2`).
*
* @param {*} cstr1 - Color specifier
* @param {*} cstr2 - Color specifier
* @param {Number} weight - Percent in [0, 100]
* @return {String} Resulting `rgb(...)` string
*/
const mix = (cstr1, cstr2, weight) => {
const c1 = parse(cstr1);
const c2 = parse(cstr2);
const p = weight / 100;
// Scale the channel weight by the alpha difference, the same way Sass does.
// A mix toward a transparent color then shifts the alpha without dragging
// the channels toward that color's meaningless rgb. Ported from libsass
// (via Qix-/color).
// https://sass-lang.com/documentation/modules/color/#mix
// https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209
const d = c2.alpha - c1.alpha;
const w = 2 * p - 1;
// `1 + w * d` is zero only at the endpoints, where one color is fully opaque
// and the other fully transparent.
const w2 = ((w * d === -1 ? w : (w + d) / (1 + w * d)) + 1) / 2;
const w1 = 1 - w2;
const blend = (x, y) => w1 * x + w2 * y;
return formatRgb({
mode: 'rgb',
r: blend(c1.r, c2.r),
g: blend(c1.g, c2.g),
b: blend(c1.b, c2.b),
alpha: c1.alpha * (1 - p) + c2.alpha * p
});
};
/**
* Pick the color from `colorList` with the highest contrast ratio against
* `baseColor`. Defaults to choosing between black and white.
*
* @param {*} baseColor - Color specifier to contrast against
* @param {Array} [colorList=['#000', '#fff']] - Candidate color specifiers
* @return {String} Resulting `rgb(...)` string
*/
const mostReadable = (baseColor, colorList = ['#000', '#fff']) => {
let bestColor;
let bestContrast = -Infinity;
for (const cstr of colorList) {
const ratio = wcagContrast(baseColor, cstr);
if (ratio > bestContrast) {
bestContrast = ratio;
bestColor = formatRgb(parse(cstr));
}
}
return bestColor;
};
/**
* Convert any color specifier to an `rgb(...)` or `rgba(...)` string,
* preserving alpha. Use `rgb()` instead when alpha must be dropped.
*
* @param {*} cstr - Color specifier
* @return {String}
*/
const rgbaString = (cstr) => formatRgb(parse(cstr));
/**
* Convert any color specifier to an uppercase `#RRGGBB` string. Alpha is dropped.
*
* @param {*} cstr - Color specifier
* @return {String}
*/
const hexString = (cstr) => formatHex(parse(cstr)).toUpperCase();
/**
* Returns the given color specifier as an `[r, g, b, a]` array, with `r`/`g`/`b`
* in [0, 255] and `a` in [0, 1]. Unrounded, since callers do further arithmetic.
*
* @param {*} cstr - Color specifier
* @return {Number[]} `[r, g, b, a]`
*/
const rgbaArray = (cstr) => {
const c = parse(cstr);
return [clamp01(c.r) * 255, clamp01(c.g) * 255, clamp01(c.b) * 255, clamp01(c.alpha)];
};
/**
* Build an `rgb()` / `rgba()` string from `[r, g, b, alpha]` channels, the inverse of
* `rgbaArray`. `r`/`g`/`b` in [0, 255], `a` in [0, 1].
*
* @param {Number[]} arr - `[r, g, b, alpha]`
* @return {String}
*/
const rgbaArrayToString = ([r, g, b, alpha]) => formatRgb({ mode: 'rgb', r: r / 255, g: g / 255, b: b / 255, alpha });
/**
* WCAG relative luminance of a color, in [0, 1].
*
* @param {*} cstr - Color specifier
* @return {Number}
*/
const luminosity = (cstr) => {
const c = parse(cstr);
return wcagLuminance({ mode: 'rgb', r: clamp01(c.r), g: clamp01(c.g), b: clamp01(c.b) });
};
module.exports = {
addOpacity,
adjustLightness,
background,
brighten,
combine,
contrast,
defaultLine,
defaults,
equals,
fill,
hexString,
interpolate,
isChannelArray,
isDark,
isValid,
lightLine,
luminosity,
mix,
mostReadable,
normalize,
opacity,
parse,
rgb,
rgbaArray,
rgbaArrayToString,
rgbaString,
stroke,
wcagContrast
};