diff --git a/draftlogs/7962_change.md b/draftlogs/7962_change.md new file mode 100644 index 00000000000..3f77b123044 --- /dev/null +++ b/draftlogs/7962_change.md @@ -0,0 +1,5 @@ +- **Breaking**: Switch color processing library from [color](https://github.com/Qix-/color) to [culori](https://culorijs.org) [[#7962](https://github.com/plotly/plotly.js/pull/7962)] + - Additional CSS Color 4 formats are now supported: `lab()`, `lch()`, `oklab()`, `oklch()`, `color()`, `hsl(0.5turn 60% 40%)`, `hsl(none 60% 40%)` + - `rgb()` now accepts an optional alpha, so `rgb(255, 0, 0, 0.5)` is no longer opaque (`rgb` and `rgba` are aliases per [spec](https://www.w3.org/TR/css-color-4/#changes-from-3)) + - Color strings that are not valid CSS are now rejected, including `hwb(200, 10%, 20%)` and `hsl(120, 50% 50%)` + - Contrasting text and border colors are picked by WCAG contrast ratio, so labels on saturated mid-tone fills may switch from dark to white diff --git a/package-lock.json b/package-lock.json index b8d60a2ddde..c24374c50f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,8 @@ "@turf/centroid": "^7.3.5", "@turf/meta": "^7.3.5", "base64-arraybuffer": "^1.0.2", - "color": "^5.0.3", - "color-normalize": "^1.5.2", "country-iso-search": "^0.1.1", + "culori": "^4.0.2", "d3-force": "^1.2.1", "d3-format": "^1.4.5", "d3-geo": "^1.12.1", @@ -2459,19 +2458,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/color-alpha": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.1.3.tgz", @@ -2544,48 +2530,6 @@ "integrity": "sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==", "license": "Unlicense" }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -2780,6 +2724,15 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/culori": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.2.tgz", + "integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", diff --git a/package.json b/package.json index 7e961db3274..336aa1e9727 100644 --- a/package.json +++ b/package.json @@ -74,9 +74,8 @@ "@turf/centroid": "^7.3.5", "@turf/meta": "^7.3.5", "base64-arraybuffer": "^1.0.2", - "color": "^5.0.3", - "color-normalize": "^1.5.2", "country-iso-search": "^0.1.1", + "culori": "^4.0.2", "d3-force": "^1.2.1", "d3-format": "^1.4.5", "d3-geo": "^1.12.1", @@ -158,9 +157,6 @@ "virtual-webgl": "^1.0.7" }, "overrides": { - "color": { - "color-string": "^2.1.4" - }, "falafel": { "acorn": "^8.1.1" }, diff --git a/src/components/color/index.js b/src/components/color/index.js index b641ad1341e..2b64939b487 100644 --- a/src/components/color/index.js +++ b/src/components/color/index.js @@ -1,37 +1,69 @@ 'use strict'; -const _color = require('color').default; -const colorNormalize = require('color-normalize'); +// 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'); + +// Clip 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. +const clip01 = (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)); + /** - * Safe wrapper around the `color` library: trims string input and falls back - * to black (with a warning) instead of throwing on invalid input. + * Parse a color specifier, falling back to opaque black. + * + * A missing color falls back quietly, because it means the caller left the + * attribute unset rather than gave a bad value. Callers that treat a missing + * color as nothing to paint test for it themselves, as `opacity` does. * * @param {*} cstr - color specifier - * @return {Color} color object + * @param {Boolean} [silent] - skip the warning, for callers that run per data point + * @return {Object} culori rgb color */ -const color = (cstr) => { - if (typeof cstr === 'string') cstr = cstr.trim(); - try { - return _color(cstr); - } catch (e) { - warn(`Invalid color specifier: "${cstr}". Defaulting to "#000"`); - return _color('#000'); +const parse = (cstr, silent) => { + const c = typeof cstr === 'string' ? toRgb(cstr.trim()) : undefined; + 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) => { - const { r, g, b } = color(cstr).rgb().object(); - return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; -}; +const rgb = (cstr) => formatRgb({ ...parse(cstr), alpha: 1 }); /** * Return the alpha channel of a color (0 if falsy). @@ -39,63 +71,80 @@ const rgb = (cstr) => { * @param {*} cstr - color specifier * @return {Number} */ -const opacity = (cstr) => (cstr ? color(cstr).alpha() : 0); +const opacity = (cstr) => (cstr ? parse(cstr).alpha : 0); + +// A per-point color in the WebGL paths can arrive as raw channels rather than as +// a color specifier, either as a plain array or as a typed array. +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. - * Accepts strings, numeric float arrays (`[0, 1]`), or uint8 arrays (`[0, 255]`). + * Falls back to opaque black rather than null: WebGL paths index the result. * - * @param {*} input - color specifier or numeric array - * @param {'float'|'uint8'} [type='float'] - `'float'` returns `[r, g, b, a]` in `[0, 1]`; - * `'uint8'` returns a `Uint8Array` in `[0, 255]`. + * @param {*} input - color specifier + * @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) => colorNormalize(input, type); +const normalize = (input, type) => { + const c = isChannelArray(input) ? channelsToRgb(input) : parse(input, true); + const v = [clip01(c.r), clip01(c.g), clip01(c.b), clip01(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 /** - * Build an `rgba(...)` string from a color and an explicit opacity value. + * Replace a color's alpha channel with `op`. * * @param {*} cstr - color specifier - * @param {Number} op - opacity in [0, 1] - * @return {String} + * @param {Number} op - opacity in [0, 1], clipped to that range + * @return {String} `rgb(...)` when the result is opaque, `rgba(...)` otherwise */ -const addOpacity = (cstr, op) => { - const c = color(cstr).rgb().object(); - return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${op})`; -}; +const addOpacity = (cstr, op) => formatRgb({ ...parse(cstr), alpha: clip01(op) }); /** * Combine two colors into one apparent color by compositing `front` over `back`. * If `back` is missing or transparent, the module `background` is assumed behind it. * + * A translucent `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) => { - back ||= background; - const fc = color(front).rgb().object(); - fc.alpha ??= 1; - if (fc.alpha === 1) return color(front).rgb().string(); - - const bc = color(back).rgb().object(); - bc.alpha ??= 1; - const bcflat = - bc.alpha === 1 - ? bc - : { - r: 255 * (1 - bc.alpha) + bc.r * bc.alpha, - g: 255 * (1 - bc.alpha) + bc.g * bc.alpha, - b: 255 * (1 - bc.alpha) + bc.b * bc.alpha - }; - - const fcflat = { - r: bcflat.r * (1 - fc.alpha) + fc.r * fc.alpha, - g: bcflat.g * (1 - fc.alpha) + fc.g * fc.alpha, - b: bcflat.b * (1 - fc.alpha) + fc.b * fc.alpha - }; - - return color(fcflat).rgb().string(); + 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) }); }; /** @@ -108,32 +157,49 @@ const combine = (front, back) => { * @return {String} resulting `rgb(...)` string */ const interpolate = (first, second, factor) => { - const fc = color(first).rgb().object(); - const sc = color(second).rgb().object(); - - const ic = { - r: factor * fc.r + (1 - factor) * sc.r, - g: factor * fc.g + (1 - factor) * sc.g, - b: factor * fc.b + (1 - factor) * sc.b - }; + const fc = parse(first); + const sc = parse(second); + const lerp = (a, b) => factor * a + (1 - factor) * b; - return color(ic).rgb().string(); + 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. Use this instead of the - * underlying library's `lighten`/`darken`, which scale L multiplicatively. + * Positive delta = lighter, negative = darker. Alpha is preserved. * * @param {*} cstr - color specifier * @param {Number} delta - lightness shift in HSL percentage points - * @return {Color} adjusted color object + * @return {String} resulting color string */ const adjustLightness = (cstr, delta) => { - const c = color(cstr); - return c.lightness(c.lightness() + delta); + const c = parse(cstr); + const h = toHsl(c) || { mode: 'hsl', h: 0, s: 0, l: 0 }; + return formatRgb(toRgb({ ...h, l: clip01((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); + /** * Create a color that contrasts with `cstr`: dark colors are lightened, * light colors are darkened. Without `lightAmount` / `darkAmount` the @@ -145,40 +211,43 @@ const adjustLightness = (cstr, delta) => { * @return {String} resulting `rgb(...)` string */ const contrast = (cstr, lightAmount, darkAmount) => { - let c = color(cstr); + if (parse(cstr).alpha !== 1) cstr = combine(cstr, background); - if (c.alpha() !== 1) c = color(combine(cstr, background)); - const newColor = c.isDark() + const newColor = isDark(cstr) ? lightAmount - ? adjustLightness(c, lightAmount) - : color(background) + ? adjustLightness(cstr, lightAmount) + : background : darkAmount - ? adjustLightness(c, -darkAmount) - : color(defaultLine); + ? adjustLightness(cstr, -darkAmount) + : defaultLine; - return newColor.rgb().string(); + return formatRgb(parse(newColor)); }; /** * Apply `stroke` and `stroke-opacity` styles to a D3 selection. * + * A missing color paints opaque black. Shapes and annotations leave + * `line.color` unset when the user gives none, and the outline still has to + * show. Use `opacity` instead when a missing color means "nothing to paint". + * * @param {Selection} s - D3 selection * @param {*} cstr - color specifier */ const stroke = (s, cstr) => { - const c = color(cstr); - s.style({ stroke: rgb(cstr), 'stroke-opacity': c.alpha() }); + s.style({ stroke: rgb(cstr), 'stroke-opacity': parse(cstr).alpha }); }; /** * Apply `fill` and `fill-opacity` styles to a D3 selection. * + * A missing color paints opaque black, the same as `stroke`. + * * @param {Selection} s - D3 selection * @param {*} cstr - color specifier */ const fill = (s, cstr) => { - const c = color(cstr); - s.style({ fill: rgb(cstr), 'fill-opacity': c.alpha() }); + s.style({ fill: rgb(cstr), 'fill-opacity': parse(cstr).alpha }); }; /** @@ -188,7 +257,7 @@ const fill = (s, cstr) => { * @param {*} cstr2 - color specifier * @return {Boolean} */ -const equals = (cstr1, cstr2) => !!(cstr1 && cstr2 && color(cstr1).rgb().string() === color(cstr2).rgb().string()); +const equals = (cstr1, cstr2) => !!(cstr1 && cstr2 && rgb(cstr1) === rgb(cstr2)); /** * Test whether a string is a valid color specifier (does not throw). @@ -196,18 +265,11 @@ const equals = (cstr1, cstr2) => !!(cstr1 && cstr2 && color(cstr1).rgb().string( * @param {*} cstr * @return {Boolean} */ -const isValid = (cstr) => { - if (typeof cstr !== 'string') return false; - try { - return !!_color(cstr.trim()); - } catch { - return false; - } -}; +const isValid = (cstr) => typeof cstr === 'string' && toRgb(cstr.trim()) !== undefined; /** * Brighten a color by adding a fixed amount to each RGB channel. - * Unlike `lighten`, this works in RGB space, not HSL. Alpha is preserved. + * Unlike `adjustLightness`, this works in RGB space, not HSL. Alpha is preserved. * * @param {*} cstr - color specifier * @param {Number} [amount=10] - percent in [-100, 100] @@ -215,16 +277,15 @@ const isValid = (cstr) => { */ const brighten = (cstr, amount) => { amount = amount === 0 ? 0 : amount || 10; - const c = color(cstr).rgb().object(); - const adj = Math.round(255 * (amount / 100)); - return color({ - r: Math.max(0, Math.min(255, c.r + adj)), - g: Math.max(0, Math.min(255, c.g + adj)), - b: Math.max(0, Math.min(255, c.b + adj)) - }) - .alpha(c.alpha ?? 1) - .rgb() - .string(); + const c = parse(cstr); + const adj = amount / 100; + + return formatRgb({ + ...c, + r: clip01(c.r + adj), + g: clip01(c.g + adj), + b: clip01(c.b + adj) + }); }; /** @@ -235,11 +296,28 @@ const brighten = (cstr, amount) => { * @param {Number} weight - percent in [0, 100] * @return {String} resulting `rgb(...)` string */ -const mix = (cstr1, cstr2, weight) => - color(cstr1) - .mix(color(cstr2), weight / 100) - .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. + const d = c2.alpha - c1.alpha; + const w = 2 * p - 1; + 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 @@ -254,35 +332,92 @@ const mostReadable = (baseColor, colorList = ['#000', '#fff']) => { let bestContrast = -Infinity; for (const cstr of colorList) { - const contrastRatio = color(baseColor).contrast(color(cstr)); - if (contrastRatio > bestContrast) { - bestContrast = contrastRatio; - bestColor = color(cstr).rgb().string(); + 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(); + +/** + * Channels as `[r, g, b, a]`, with `r`/`g`/`b` in [0, 255] and `a` in [0, 1]. + * An array rather than an object so callers cannot depend on the color library's + * shape. Unrounded, since callers do further arithmetic. + * + * @param {*} cstr - color specifier + * @return {Number[]} `[r, g, b, a]` + */ +const rgbaArray = (cstr) => { + const c = parse(cstr); + return [clip01(c.r) * 255, clip01(c.g) * 255, clip01(c.b) * 255, clip01(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: clip01(c.r), g: clip01(c.g), b: clip01(c.b) }); +}; + module.exports = { addOpacity, adjustLightness, background, brighten, - color, combine, contrast, defaultLine, defaults, equals, fill, + hexString, interpolate, + isDark, isValid, lightLine, + luminosity, mix, mostReadable, normalize, opacity, + parse, rgb, - stroke + rgbaArray, + rgbaArrayToString, + rgbaString, + stroke, + wcagContrast }; diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index 879088b9057..b749fdcb56f 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -549,7 +549,7 @@ function drawColorBar(g, opts, gd) { } else { // The color library can't handle exponents and at this scale, removing it makes no difference. var colorString = fillColormap(d).replace('e-', ''); - fillEl.attr('fill', Color.color(colorString).hex()); + fillEl.attr('fill', Color.hexString(colorString)); } }); diff --git a/src/components/colorscale/helpers.js b/src/components/colorscale/helpers.js index 275b134fc35..9b6c31eb699 100644 --- a/src/components/colorscale/helpers.js +++ b/src/components/colorscale/helpers.js @@ -166,8 +166,7 @@ function makeColorScaleFunc(specs, opts) { var _range = new Array(N); for(var i = 0; i < N; i++) { - const { r, g, b, alpha = 1 } = Color.color(range[i]).rgb().object(); - _range[i] = [r, g, b, alpha]; + _range[i] = Color.rgbaArray(range[i]); } var _sclFunc = d3.scale.linear() @@ -183,7 +182,7 @@ function makeColorScaleFunc(specs, opts) { sclFunc = _sclFunc; } else if(noNumericCheck) { sclFunc = function(v) { - return colorArray2rbga(_sclFunc(v)); + return Color.rgbaArrayToString(_sclFunc(v)); }; } else if(returnArray) { sclFunc = function(v) { @@ -193,7 +192,7 @@ function makeColorScaleFunc(specs, opts) { }; } else { sclFunc = function(v) { - if(isNumeric(v)) return colorArray2rbga(_sclFunc(v)); + if(isNumeric(v)) return Color.rgbaArrayToString(_sclFunc(v)); if(Color.isValid(v)) return v; return Color.defaultLine; }; @@ -210,17 +209,6 @@ function makeColorScaleFuncFromTrace(trace, opts) { return makeColorScaleFunc(extractScale(trace), opts); } -function colorArray2rbga(colorArray) { - var colorObj = { - r: colorArray[0], - g: colorArray[1], - b: colorArray[2], - alpha: colorArray[3] - }; - - return Color.color(colorObj).rgb().string(); -} - module.exports = { hasColorscale: hasColorscale, extractOpts: extractOpts, diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 748b1a91e60..3a71319c838 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -514,11 +514,10 @@ function gradientWithBounds(sel, gd, gradientID, type, colorscale, prop, start, stops.enter().append('stop'); stops.each(function (d) { - var c = Color.color(d[1]); d3.select(this).attr({ offset: d[0] + '%', 'stop-color': Color.rgb(d[1]), - 'stop-opacity': c.alpha() + 'stop-opacity': Color.parse(d[1]).alpha }); }); }); @@ -584,9 +583,8 @@ drawing.pattern = function ( var patternTag; var patternAttrs = {}; - var fgC = Color.color(fgcolor); var fgRGB = Color.rgb(fgcolor); - var fgAlpha = fgC.alpha(); + var fgAlpha = Color.parse(fgcolor).alpha; var opacity = fgopacity * fgAlpha; switch (shape) { @@ -819,9 +817,8 @@ drawing.pattern = function ( }); if (bgcolor) { - var bgC = Color.color(bgcolor); var bgRGB = Color.rgb(bgcolor); - var bgAlpha = bgC.alpha(); + var bgAlpha = Color.parse(bgcolor).alpha; var rects = el.selectAll('rect').data([0]); rects.exit().remove(); diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 05c03ebdfb1..68b89fc4c7e 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -2287,7 +2287,7 @@ function createSpikelines(gd, closestPoints, opts) { hLinePointY = ya._offset + hLinePoint.y; } var dfltHLineColor = - Color.color(hLinePoint.color).contrast(Color.color(contrastColor)) < 1.5 + Color.wcagContrast(hLinePoint.color, contrastColor) < 1.5 ? Color.contrast(contrastColor) : hLinePoint.color; var yMode = ya.spikemode; @@ -2372,7 +2372,7 @@ function createSpikelines(gd, closestPoints, opts) { } var dfltVLineColor = - Color.color(vLinePoint.color).contrast(Color.color(contrastColor)) < 1.5 + Color.wcagContrast(vLinePoint.color, contrastColor) < 1.5 ? Color.contrast(contrastColor) : vLinePoint.color; var xMode = xa.spikemode; diff --git a/src/lib/gl_format_color.js b/src/lib/gl_format_color.js index 93c9c98f81c..da74a7ea81e 100644 --- a/src/lib/gl_format_color.js +++ b/src/lib/gl_format_color.js @@ -4,25 +4,25 @@ var isNumeric = require('fast-isnumeric'); var Colorscale = require('../components/colorscale'); var Color = require('../components/color'); -var rgba = Color.normalize; var colorDflt = require('../components/color/attributes').defaultLine; var isArrayOrTypedArray = require('./array').isArrayOrTypedArray; -var colorDfltRgba = rgba(colorDflt); +var colorDfltRgba = Color.normalize(colorDflt); var opacityDflt = 1; function calculateColor(colorIn, opacityIn) { - var colorOut = colorIn; - colorOut[3] *= opacityIn; - return colorOut; + // Return a new array to avoid mutating the original + return [colorIn[0], colorIn[1], colorIn[2], colorIn[3] * opacityIn]; } function validateColor(colorIn) { - if(isNumeric(colorIn)) return colorDfltRgba; + if (isNumeric(colorIn)) return colorDfltRgba; - var colorOut = rgba(colorIn); + // A per-point color may be raw channels rather than a color string, which + // `Color.isValid` rejects but `Color.normalize` handles. + if (!isArrayOrTypedArray(colorIn) && !Color.isValid(colorIn)) return colorDfltRgba; - return colorOut.length ? colorOut : colorDfltRgba; + return Color.normalize(colorIn); } function validateOpacity(opacityIn) { @@ -47,9 +47,10 @@ function formatColor(containerIn, opacityIn, len) { } if(isArrayColorIn) { - getColor = function(c, i) { - // FIXME: there is double work, considering that sclFunc does the opposite - return c[i] === undefined ? colorDfltRgba : rgba(sclFunc(c[i])); + getColor = (c, i) => { + if (c[i] === undefined) return colorDfltRgba; + // Only normalize sclFunc output when a colorscale exists (because it's a color string) + return cOpts.colorscale === undefined ? sclFunc(c[i]) : Color.normalize(sclFunc(c[i])); }; } else getColor = validateColor; @@ -59,13 +60,13 @@ function formatColor(containerIn, opacityIn, len) { }; } else getOpacity = validateOpacity; - if(isArrayColorIn || isArrayOpacityIn) { - for(var i = 0; i < len; i++) { + if (isArrayColorIn || isArrayOpacityIn) { + for (var i = 0; i < len; i++) { colori = getColor(colorIn, i); opacityi = getOpacity(opacityIn, i); colorOut[i] = calculateColor(colori, opacityi); } - } else colorOut = calculateColor(rgba(colorIn), opacityIn); + } else colorOut = calculateColor(validateColor(colorIn), opacityIn); return colorOut; } @@ -77,16 +78,14 @@ function parseColorScale(cont) { if(cOpts.reversescale) colorscale = Colorscale.flipScale(cOpts.colorscale); return colorscale.map(function(elem) { - var index = elem[0]; - const { r, g, b, alpha = 1 } = Color.color(elem[1]).rgb().object(); return { - index: index, - rgb: [r, g, b, alpha] + index: elem[0], + rgb: Color.rgbaArray(elem[1]) }; }); } module.exports = { - formatColor: formatColor, - parseColorScale: parseColorScale + formatColor, + parseColorScale }; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 26006c27c9d..4328965f9ac 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -335,7 +335,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { box = {l: x0, r: x0, w: 0, t: y0, b: y0, h: 0}; lum = gd._hmpixcount ? (gd._hmlumcount / gd._hmpixcount) - : Color.color(gd._fullLayout.plot_bgcolor).luminosity(); + : Color.luminosity(gd._fullLayout.plot_bgcolor); path0 = 'M0,0H' + pw + 'V' + ph + 'H0V0'; dimmed = false; zoomMode = 'xy'; diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js index 7e113aa2021..b21a06823f3 100644 --- a/src/plots/polar/polar.js +++ b/src/plots/polar/polar.js @@ -934,7 +934,7 @@ proto.updateHoverAndMainDrag = function(fullLayout) { dimmed = false; var polarLayoutNow = gd._fullLayout[_this.id]; - lum = Color.color(polarLayoutNow.bgcolor).luminosity(); + lum = Color.luminosity(polarLayoutNow.bgcolor); zb = dragBox.makeZoombox(zoomlayer, lum, cx, cy, path0); zb.attr('fill-rule', 'evenodd'); diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 03ad0e8be49..836bee45a8f 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -592,7 +592,7 @@ proto.initInteractions = function() { }; mins = mins0; span0 = _this.aaxis.range[1] - mins0.a; - lum = Color.color(_this.graphDiv._fullLayout[_this.id].bgcolor).luminosity(); + lum = Color.luminosity(_this.graphDiv._fullLayout[_this.id].bgcolor); path0 = 'M0,' + _this.h + 'L' + (_this.w / 2) + ', 0L' + _this.w + ',' + _this.h + 'Z'; dimmed = false; diff --git a/src/traces/heatmap/plot.js b/src/traces/heatmap/plot.js index 901974044c2..05c30d3005d 100644 --- a/src/traces/heatmap/plot.js +++ b/src/traces/heatmap/plot.js @@ -351,7 +351,7 @@ module.exports = function (gd, plotinfo, cdheatmaps, heatmapLayer) { const cstr = `rgb(${rcount}, ${gcount}, ${bcount})`; gd._hmpixcount = (gd._hmpixcount || 0) + pixcount; - gd._hmlumcount = (gd._hmlumcount || 0) + pixcount * Color.color(cstr).luminosity(); + gd._hmlumcount = (gd._hmlumcount || 0) + pixcount * Color.luminosity(cstr); } var image3 = plotGroup.selectAll('image').data(cd); diff --git a/src/traces/parcoords/lines.js b/src/traces/parcoords/lines.js index 208f7b11690..15790801757 100644 --- a/src/traces/parcoords/lines.js +++ b/src/traces/parcoords/lines.js @@ -336,12 +336,13 @@ function makeItem( dim1D: dims[1].slice(48, 64), drwLayer: drwLayer, + // These are normalized color channel values (0-1) contextColor: [ - deselectedLinesColor.red() / 255, - deselectedLinesColor.green() / 255, - deselectedLinesColor.blue() / 255, + deselectedLinesColor[0], + deselectedLinesColor[1], + deselectedLinesColor[2], deselectedLinesOpacity !== 'auto' ? - deselectedLinesColor.alpha() * deselectedLinesOpacity : + deselectedLinesColor[3] * deselectedLinesOpacity : Math.max(1 / 255, Math.pow(1 / model.lines.color.length, 1 / 3)) ], diff --git a/src/traces/parcoords/parcoords.js b/src/traces/parcoords/parcoords.js index 8cd6ca53e25..da337966199 100644 --- a/src/traces/parcoords/parcoords.js +++ b/src/traces/parcoords/parcoords.js @@ -4,7 +4,7 @@ var d3 = require('@plotly/d3'); var Lib = require('../../lib'); var isArrayOrTypedArray = Lib.isArrayOrTypedArray; var numberFormat = Lib.numberFormat; -var Color = require('color').default; +var Color = require('../../components/color'); var Axes = require('../../plots/cartesian/axes'); var strRotate = Lib.strRotate; @@ -118,8 +118,8 @@ function ordinalScale(dimension) { function unitToColorScale(cscale) { var colorStops = cscale.map(function(d) { return d[0]; }); var colorTuples = cscale.map(function(d) { - var c = Color(d[1]); - return d3.rgb(c.red(), c.green(), c.blue()); + const [r, g, b] = Color.rgbaArray(d[1]); + return d3.rgb(r, g, b); }); var prop = function(n) { return function(o) { return o[n]; }; }; @@ -151,7 +151,7 @@ function model(layout, d, i) { var lineColor = helpers.convertTypedArray(cd0.lineColor); var line = trace.line; var deselectedLines = { - color: Color(trace.unselected.line.color), + color: Color.normalize(trace.unselected.line.color), opacity: trace.unselected.line.opacity }; var cOpts = Colorscale.extractOpts(line); diff --git a/src/traces/pie/calc.js b/src/traces/pie/calc.js index e480bb1cb4f..42c3d3dc67e 100644 --- a/src/traces/pie/calc.js +++ b/src/traces/pie/calc.js @@ -90,7 +90,7 @@ function makePullColorFn(colorMap) { return function pullColor(color, id) { if(!color || !Color.isValid(color)) return false; - const newColor = Color.color(color).rgb().string(); + const newColor = Color.rgbaString(color); if(!colorMap[id]) colorMap[id] = newColor; return newColor; @@ -150,11 +150,11 @@ function generateExtendedColors(colorList, extendedColorWays) { colors = colorList.slice(); for(i = 0; i < colorList.length; i++) { - colors.push(Color.adjustLightness(colorList[i], 20).hex()); + colors.push(Color.hexString(Color.adjustLightness(colorList[i], 20))); } for(i = 0; i < colorList.length; i++) { - colors.push(Color.adjustLightness(colorList[i], -20).hex()); + colors.push(Color.hexString(Color.adjustLightness(colorList[i], -20))); } extendedColorWays[colorString] = colors; } diff --git a/src/traces/sankey/defaults.js b/src/traces/sankey/defaults.js index 98a8e1a018e..785e843267a 100644 --- a/src/traces/sankey/defaults.js +++ b/src/traces/sankey/defaults.js @@ -65,7 +65,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerceLink('hovertemplate'); coerceLink('sort'); - var darkBG = Color.color(layout.paper_bgcolor).luminosity() < 0.333; + var darkBG = Color.luminosity(layout.paper_bgcolor) < 0.333; var defaultLinkColor = darkBG ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'; var linkColor = coerceLink('color', defaultLinkColor); @@ -73,16 +73,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout // hopefully the user-specified color is valid, but if not that can be caught elsewhere if (!Color.isValid(_linkColor)) return _linkColor; - const c = Color.color(_linkColor); - const alpha = c.alpha(); + const alpha = Color.opacity(_linkColor); if (alpha <= 0.8) { - return c - .alpha(alpha + 0.2) - .rgb() - .string(); + return Color.addOpacity(_linkColor, alpha + 0.2); } else { - return darkBG ? Color.brighten(c, 10) : Color.adjustLightness(c, -10).rgb().string(); + return darkBG ? Color.brighten(_linkColor, 10) : Color.adjustLightness(_linkColor, -10); } } diff --git a/src/traces/sankey/render.js b/src/traces/sankey/render.js index 9a5ecfbb494..83f8bbb2f98 100644 --- a/src/traces/sankey/render.js +++ b/src/traces/sankey/render.js @@ -330,9 +330,9 @@ function linkModel(d, l, i) { pointNumber: l.pointNumber, link: l, rgb: Color.rgb(l.color), - alpha: Color.color(l.color).alpha(), + alpha: Color.parse(l.color).alpha, hoverRgb: Color.rgb(l.hovercolor), - hoverAlpha: Color.color(l.hovercolor).alpha(), + hoverAlpha: Color.parse(l.hovercolor).alpha, linkPath: linkPath, linkLineColor: d.linkLineColor, linkLineWidth: d.linkLineWidth, @@ -593,9 +593,8 @@ function nodeModel(d, n) { forceLayouts: d.forceLayouts, horizontal: d.horizontal, reversed: d.reversed, - darkBackground: Color.color(n.color).isDark(), rgb: Color.rgb(n.color), - alpha: Color.color(n.color).alpha(), + alpha: Color.parse(n.color).alpha, valueFormat: d.valueFormat, valueSuffix: d.valueSuffix, sankey: d.sankey, diff --git a/test/image/baselines/4.png b/test/image/baselines/4.png index 2d1d1fd4a79..dbe44b5ebe6 100644 Binary files a/test/image/baselines/4.png and b/test/image/baselines/4.png differ diff --git a/test/image/baselines/bar-like_textangle45.png b/test/image/baselines/bar-like_textangle45.png index d13af973068..f116dbf9db0 100644 Binary files a/test/image/baselines/bar-like_textangle45.png and b/test/image/baselines/bar-like_textangle45.png differ diff --git a/test/image/baselines/bar-like_textangle60.png b/test/image/baselines/bar-like_textangle60.png index f9da7ba848b..91eaa7e1248 100644 Binary files a/test/image/baselines/bar-like_textangle60.png and b/test/image/baselines/bar-like_textangle60.png differ diff --git a/test/image/baselines/bar-like_traces_no-tozero.png b/test/image/baselines/bar-like_traces_no-tozero.png index 4e0b7cd91cc..9f1743aa9a0 100644 Binary files a/test/image/baselines/bar-like_traces_no-tozero.png and b/test/image/baselines/bar-like_traces_no-tozero.png differ diff --git a/test/image/baselines/bar-like_traces_no-tozero_negative.png b/test/image/baselines/bar-like_traces_no-tozero_negative.png index fe9c158e8ac..2a6980acd34 100644 Binary files a/test/image/baselines/bar-like_traces_no-tozero_negative.png and b/test/image/baselines/bar-like_traces_no-tozero_negative.png differ diff --git a/test/image/baselines/bar-like_traces_tozero.png b/test/image/baselines/bar-like_traces_tozero.png index 95c051743ca..67ff3bbf6b7 100644 Binary files a/test/image/baselines/bar-like_traces_tozero.png and b/test/image/baselines/bar-like_traces_tozero.png differ diff --git a/test/image/baselines/bar_gantt-chart.png b/test/image/baselines/bar_gantt-chart.png index 2ad18149796..a6e0c1f1af0 100644 Binary files a/test/image/baselines/bar_gantt-chart.png and b/test/image/baselines/bar_gantt-chart.png differ diff --git a/test/image/baselines/color_syntax_formats.png b/test/image/baselines/color_syntax_formats.png index e78032e78f0..18fc9e60383 100644 Binary files a/test/image/baselines/color_syntax_formats.png and b/test/image/baselines/color_syntax_formats.png differ diff --git a/test/image/baselines/contour_heatmap_coloring_reversescale.png b/test/image/baselines/contour_heatmap_coloring_reversescale.png index b8567f3e574..dbfbdfeeff6 100644 Binary files a/test/image/baselines/contour_heatmap_coloring_reversescale.png and b/test/image/baselines/contour_heatmap_coloring_reversescale.png differ diff --git a/test/image/baselines/display-text_zero-number.png b/test/image/baselines/display-text_zero-number.png index ca9c74dd8a6..9c75f2ba312 100644 Binary files a/test/image/baselines/display-text_zero-number.png and b/test/image/baselines/display-text_zero-number.png differ diff --git a/test/image/baselines/funnel_multicategory.png b/test/image/baselines/funnel_multicategory.png index b3e34c11f2f..b4bc0ded96c 100644 Binary files a/test/image/baselines/funnel_multicategory.png and b/test/image/baselines/funnel_multicategory.png differ diff --git a/test/image/baselines/gl2d_rgb_dont_accept_alpha_scattergl.png b/test/image/baselines/gl2d_rgb_dont_accept_alpha_scattergl.png deleted file mode 100644 index c39698b8d3c..00000000000 Binary files a/test/image/baselines/gl2d_rgb_dont_accept_alpha_scattergl.png and /dev/null differ diff --git a/test/image/baselines/gl3d_rgb_dont_accept_alpha_scatter3d.png b/test/image/baselines/gl3d_rgb_dont_accept_alpha_scatter3d.png deleted file mode 100644 index dd3cc0fe962..00000000000 Binary files a/test/image/baselines/gl3d_rgb_dont_accept_alpha_scatter3d.png and /dev/null differ diff --git a/test/image/baselines/gl3d_surface-heatmap-treemap_transparent-colorscale.png b/test/image/baselines/gl3d_surface-heatmap-treemap_transparent-colorscale.png index 5322c99472c..f8cc5bc374b 100644 Binary files a/test/image/baselines/gl3d_surface-heatmap-treemap_transparent-colorscale.png and b/test/image/baselines/gl3d_surface-heatmap-treemap_transparent-colorscale.png differ diff --git a/test/image/baselines/heatmap_xyz-gaps-on-sides.png b/test/image/baselines/heatmap_xyz-gaps-on-sides.png index 27bdf2c5903..97308edb4c6 100644 Binary files a/test/image/baselines/heatmap_xyz-gaps-on-sides.png and b/test/image/baselines/heatmap_xyz-gaps-on-sides.png differ diff --git a/test/image/baselines/icicle_packages_colorscale_novalue.png b/test/image/baselines/icicle_packages_colorscale_novalue.png index 7d7567f5e7c..84ed0105730 100644 Binary files a/test/image/baselines/icicle_packages_colorscale_novalue.png and b/test/image/baselines/icicle_packages_colorscale_novalue.png differ diff --git a/test/image/baselines/sunburst_packages_colorscale_novalue.png b/test/image/baselines/sunburst_packages_colorscale_novalue.png index 27c7abd4865..cf5010d997b 100644 Binary files a/test/image/baselines/sunburst_packages_colorscale_novalue.png and b/test/image/baselines/sunburst_packages_colorscale_novalue.png differ diff --git a/test/image/baselines/texttemplate.png b/test/image/baselines/texttemplate.png index 83bb660ca18..cab575d81e2 100644 Binary files a/test/image/baselines/texttemplate.png and b/test/image/baselines/texttemplate.png differ diff --git a/test/image/baselines/treemap_fonts_withcolor.png b/test/image/baselines/treemap_fonts_withcolor.png index f24af3c11cd..628d886769f 100644 Binary files a/test/image/baselines/treemap_fonts_withcolor.png and b/test/image/baselines/treemap_fonts_withcolor.png differ diff --git a/test/image/baselines/treemap_packages_colorscale_novalue.png b/test/image/baselines/treemap_packages_colorscale_novalue.png index dc5e341ea40..5b1049417f1 100644 Binary files a/test/image/baselines/treemap_packages_colorscale_novalue.png and b/test/image/baselines/treemap_packages_colorscale_novalue.png differ diff --git a/test/image/baselines/treemap_sunburst_basic.png b/test/image/baselines/treemap_sunburst_basic.png index 0ba5c1f72fc..53ebd5574aa 100644 Binary files a/test/image/baselines/treemap_sunburst_basic.png and b/test/image/baselines/treemap_sunburst_basic.png differ diff --git a/test/image/baselines/treemap_sunburst_marker_colors.png b/test/image/baselines/treemap_sunburst_marker_colors.png index 9062a98a621..eeb79363a51 100644 Binary files a/test/image/baselines/treemap_sunburst_marker_colors.png and b/test/image/baselines/treemap_sunburst_marker_colors.png differ diff --git a/test/image/baselines/uniformtext_bar-like_10_auto.png b/test/image/baselines/uniformtext_bar-like_10_auto.png index b87768a9b86..c762c6ca00d 100644 Binary files a/test/image/baselines/uniformtext_bar-like_10_auto.png and b/test/image/baselines/uniformtext_bar-like_10_auto.png differ diff --git a/test/image/baselines/uniformtext_bar-like_8_horizontal.png b/test/image/baselines/uniformtext_bar-like_8_horizontal.png index be845b5e7c9..39d7db04a1c 100644 Binary files a/test/image/baselines/uniformtext_bar-like_8_horizontal.png and b/test/image/baselines/uniformtext_bar-like_8_horizontal.png differ diff --git a/test/image/baselines/uniformtext_bar-like_8_textangle.png b/test/image/baselines/uniformtext_bar-like_8_textangle.png index 3ffbbec7d14..2bc7e3e2ec4 100644 Binary files a/test/image/baselines/uniformtext_bar-like_8_textangle.png and b/test/image/baselines/uniformtext_bar-like_8_textangle.png differ diff --git a/test/image/baselines/uniformtext_bar-like_8_textangle45.png b/test/image/baselines/uniformtext_bar-like_8_textangle45.png index ebb0f49c346..e07fc43b066 100644 Binary files a/test/image/baselines/uniformtext_bar-like_8_textangle45.png and b/test/image/baselines/uniformtext_bar-like_8_textangle45.png differ diff --git a/test/image/baselines/uniformtext_icicle.png b/test/image/baselines/uniformtext_icicle.png index f35e9b3d698..9242dfcd808 100644 Binary files a/test/image/baselines/uniformtext_icicle.png and b/test/image/baselines/uniformtext_icicle.png differ diff --git a/test/image/baselines/uniformtext_treemap.png b/test/image/baselines/uniformtext_treemap.png index 8f04e35024c..7130338963b 100644 Binary files a/test/image/baselines/uniformtext_treemap.png and b/test/image/baselines/uniformtext_treemap.png differ diff --git a/test/image/baselines/waterfall_axis.png b/test/image/baselines/waterfall_axis.png index 472f4304a18..142a9726135 100644 Binary files a/test/image/baselines/waterfall_axis.png and b/test/image/baselines/waterfall_axis.png differ diff --git a/test/image/baselines/waterfall_funnel_texttemplate_date.png b/test/image/baselines/waterfall_funnel_texttemplate_date.png index 48d8937b810..cacf1534808 100644 Binary files a/test/image/baselines/waterfall_funnel_texttemplate_date.png and b/test/image/baselines/waterfall_funnel_texttemplate_date.png differ diff --git a/test/image/mocks/color_syntax_formats.json b/test/image/mocks/color_syntax_formats.json index 000da14f3a6..086a4cc4f2f 100644 --- a/test/image/mocks/color_syntax_formats.json +++ b/test/image/mocks/color_syntax_formats.json @@ -5,77 +5,99 @@ "name": "'#d62728' (hex 6)", "x": [0], "y": [3], - "marker": { "color": "#d62728" } + "marker": { + "color": "#d62728" + } }, { "type": "bar", "name": "'#1f77b4cc' (hex 8 alpha)", "x": [1], "y": [3], - "marker": { "color": "#1f77b4cc" } + "marker": { + "color": "#1f77b4cc" + } }, { "type": "bar", "name": "'#f00' (hex 3 short)", "x": [2], "y": [3], - "marker": { "color": "#f00" } + "marker": { + "color": "#f00" + } }, { "type": "bar", "name": "'#0f08' (hex 4 short alpha)", "x": [3], "y": [3], - "marker": { "color": "#0f08" } + "marker": { + "color": "#0f08" + } }, { "type": "bar", "name": "'rgb(255 165 0)' (space)", "x": [5], "y": [3], - "marker": { "color": "rgb(255 165 0)" } + "marker": { + "color": "rgb(255 165 0)" + } }, { "type": "bar", "name": "'rgba(128 0 128 / 0.6)' (slash alpha)", "x": [6], "y": [3], - "marker": { "color": "rgba(128 0 128 / 0.6)" } + "marker": { + "color": "rgba(128 0 128 / 0.6)" + } }, { "type": "bar", "name": "'hsl(210, 80%, 50%)'", "x": [8], "y": [3], - "marker": { "color": "hsl(210, 80%, 50%)" } + "marker": { + "color": "hsl(210, 80%, 50%)" + } }, { "type": "bar", "name": "'hsla(0, 90%, 55%, 0.7)'", "x": [9], "y": [3], - "marker": { "color": "hsla(0, 90%, 55%, 0.7)" } + "marker": { + "color": "hsla(0, 90%, 55%, 0.7)" + } }, { "type": "bar", "name": "'hsl(120deg 60% 40%)' (modern)", "x": [10], "y": [3], - "marker": { "color": "hsl(120deg 60% 40%)" } + "marker": { + "color": "hsl(120deg 60% 40%)" + } }, { "type": "bar", - "name": "'hwb(200, 10%, 20%)'", + "name": "'hwb(200 10% 20%)'", "x": [12], "y": [3], - "marker": { "color": "hwb(200, 10%, 20%)" } + "marker": { + "color": "hwb(200 10% 20%)" + } }, { "type": "bar", "name": "'rebeccapurple' (named)", "x": [13], "y": [3], - "marker": { "color": "rebeccapurple" } + "marker": { + "color": "rebeccapurple" + } }, { "type": "bar", @@ -84,31 +106,147 @@ "y": [3], "marker": { "color": "transparent", - "line": { "color": " #444 ", "width": 1 } + "line": { + "color": " #444 ", + "width": 1 + } + } + }, + { + "type": "bar", + "name": "'hsl(0.5turn 60% 40%)' (turn)", + "x": [16], + "y": [3], + "marker": { + "color": "hsl(0.5turn 60% 40%)" + } + }, + { + "type": "bar", + "name": "'hsl(none 60% 40%)' (none)", + "x": [17], + "y": [3], + "marker": { + "color": "hsl(none 60% 40%)" + } + }, + { + "type": "bar", + "name": "'hsl(120 60 40)' (unitless)", + "x": [18], + "y": [3], + "marker": { + "color": "hsl(120 60 40)" + } + }, + { + "type": "bar", + "name": "'hsl(210 80% 50% / 0.6)' (slash)", + "x": [19], + "y": [3], + "marker": { + "color": "hsl(210 80% 50% / 0.6)" + } + }, + { + "type": "bar", + "name": "'lab(50% 40 59.5)'", + "x": [21], + "y": [3], + "marker": { + "color": "lab(50% 40 59.5)" + } + }, + { + "type": "bar", + "name": "'lch(50% 70 40)'", + "x": [22], + "y": [3], + "marker": { + "color": "lch(50% 70 40)" + } + }, + { + "type": "bar", + "name": "'oklab(0.5 0.1 0.1)'", + "x": [23], + "y": [3], + "marker": { + "color": "oklab(0.5 0.1 0.1)" + } + }, + { + "type": "bar", + "name": "'oklch(0.7 0.15 180)'", + "x": [24], + "y": [3], + "marker": { + "color": "oklch(0.7 0.15 180)" + } + }, + { + "type": "bar", + "name": "'color(srgb 1 0 0)'", + "x": [26], + "y": [3], + "marker": { + "color": "color(srgb 1 0 0)" + } + }, + { + "type": "bar", + "name": "'color(display-p3 0 1 0)' (wide)", + "x": [27], + "y": [3], + "marker": { + "color": "color(display-p3 0 1 0)" + } + }, + { + "type": "bar", + "name": "'color(rec2020 0 0 1)' (wide)", + "x": [28], + "y": [3], + "marker": { + "color": "color(rec2020 0 0 1)" } } ], "layout": { - "title": { "text": "Modern color syntax formats" }, + "title": { + "text": "Modern color syntax formats" + }, "barmode": "group", "bargap": 0.1, - "width": 900, - "height": 500, + "width": 1400, + "height": 630, "paper_bgcolor": "rgb(250 250 250)", "plot_bgcolor": "hsl(0, 0%, 98%)", "xaxis": { "tickmode": "array", - "tickvals": [1.5, 5.5, 9, 13], - "ticktext": ["hex", "rgb", "hsl", "other"], + "tickvals": [1.5, 5.5, 9, 13, 17.5, 22.5, 27], + "ticktext": [ + "hex", + "rgb", + "hsl", + "other", + "hsl (css4)", + "lab / lch", + "color()" + ], "showgrid": false, - "range": [-1, 15] + "range": [-1, 29] + }, + "yaxis": { + "gridcolor": "#00000014" }, - "yaxis": { "gridcolor": "#00000014" }, "legend": { "bgcolor": "#ffffffcc", - "bordercolor": "hwb(0deg, 50%, 50%)", + "bordercolor": "hwb(0deg 50% 50%)", "borderwidth": 1 }, - "font": { "color": " rgb(40, 40, 40) " } + "font": { + "color": " rgb(40, 40, 40) " + } } } diff --git a/test/image/mocks/gl2d_rgb_dont_accept_alpha_scattergl.json b/test/image/mocks/gl2d_rgb_dont_accept_alpha_scattergl.json deleted file mode 100644 index d4eb20a6ed8..00000000000 --- a/test/image/mocks/gl2d_rgb_dont_accept_alpha_scattergl.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "data": [ - { - "name": "rgb(r,g,b,a)", - "type": "scattergl", - "x": [1, 2, 3, 4], - "y": [1, 1, 1, 1], - "mode": "marker+line", - "marker": { - "size": 20, - "color": [ - "rgb(0, 0, 0, 0.5)", - "rgb(255, 0, 0, 0.5)", - "rgb(0, 255, 0, 0.5)", - "rgb(0, 0, 255, 0.5)" - ], - "line": { - "width": 10, - "color": [ - "rgb(191, 191, 191, 0.5)", - "rgb(0, 255, 255, 0.5)", - "rgb(255, 0, 255, 0.5)", - "rgb(255, 255, 0, 0.5)" - ] - } - }, - "line": { - "width": 20 - } - }, - { - "name": "rgb(r,g,b,a)+opacity", - "type": "scattergl", - "x": [1, 2, 3, 4], - "y": [0.5, 0.5, 0.5, 0.5], - "opacity": 0.5, - "mode": "marker+line", - "marker": { - "size": 20, - "color": [ - "rgb(0, 0, 0, 0.5)", - "rgb(255, 0, 0, 0.5)", - "rgb(0, 255, 0, 0.5)", - "rgb(0, 0, 255, 0.5)" - ], - "line": { - "width": 10, - "color": [ - "rgb(191, 191, 191, 0.5)", - "rgb(0, 255, 255, 0.5)", - "rgb(255, 0, 255, 0.5)", - "rgb(255, 255, 0, 0.5)" - ] - } - }, - "line": { - "width": 20 - } - }, - { - "name": "rgba(r,g,b,a)", - "type": "scattergl", - "x": [1, 2, 3, 4], - "y": [0, 0, 0, 0], - "mode": "marker+line", - "marker": { - "size": 20, - "color": [ - "rgba(0, 0, 0, 0.5)", - "rgba(255, 0, 0, 0.5)", - "rgba(0, 255, 0, 0.5)", - "rgba(0, 0, 255, 0.5)" - ], - "line": { - "width": 10, - "color": [ - "rgba(191, 191, 191, 0.5)", - "rgba(0, 255, 255, 0.5)", - "rgba(255, 0, 255, 0.5)", - "rgba(255, 255, 0, 0.5)" - ] - } - }, - "line": { - "width": 20 - } - } - ], - "layout": { - "width": 600, - "height": 400, - "xaxis": { "nticks": 20 }, - "yaxis": { "nticks": 20 }, - "title": { - "text": "Should not display alpha
when having rgb not rgba" - } - } -} diff --git a/test/image/mocks/gl3d_rgb_dont_accept_alpha_scatter3d.json b/test/image/mocks/gl3d_rgb_dont_accept_alpha_scatter3d.json deleted file mode 100644 index b9e0a31bb84..00000000000 --- a/test/image/mocks/gl3d_rgb_dont_accept_alpha_scatter3d.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "data": [ - { - "type": "scatter3d", - "x": [15, 25, 35, 15], - "y": [0, 0, 1, 2], - "z": [0, 2, 0, 1], - "marker": { - "size": 20, - "color": [ - "rgb(0, 0, 0, 0.5)", - "rgb(255, 0, 0, 0.5)", - "rgb(0, 255, 0, 0.5)", - "rgb(0, 0, 255, 0.5)" - ], - "line": { - "width": 20, - "color": [ - "rgb(191, 191, 191, 0.5)", - "rgb(0, 255, 255, 0.5)", - "rgb(255, 0, 255, 0.5)", - "rgb(255, 255, 0, 0.5)" - ] - } - }, - "line": { - "width": 20, - "color": [ - "rgb(0, 255, 255, 0.5)", - "rgb(255, 0, 255, 0.5)", - "rgb(255, 255, 0, 0.5)" - ] - } - }, - { - "type": "scatter3d", - "x": [-15, -25, -35, -15], - "y": [0, 0, 1, 2], - "z": [0, 2, 0, 1], - "marker": { - "size": 20, - "color": [ - "rgba(0, 0, 0, 0.5)", - "rgba(255, 0, 0, 0.5)", - "rgba(0, 255, 0, 0.5)", - "rgba(0, 0, 255, 0.5)" - ], - "line": { - "width": 20, - "color": [ - "rgba(191, 191, 191, 0.5)", - "rgba(0, 255, 255, 0.5)", - "rgba(255, 0, 255, 0.5)", - "rgba(255, 255, 0, 0.5)" - ] - } - }, - "line": { - "width": 20, - "color": [ - "rgba(0, 255, 255, 0.5)", - "rgba(255, 0, 255, 0.5)", - "rgba(255, 255, 0, 0.5)" - ] - } - } - ], - "layout": { - "width": 400, - "height": 400, - "title": { - "text": "Should not display alpha
when having rgb not rgba" - }, - "scene": { - "camera": { - "eye": { - "x": 0, - "y": 1, - "z": 2 - } - } - } - } -} diff --git a/test/jasmine/assets/check_contrasting_text.js b/test/jasmine/assets/check_contrasting_text.js new file mode 100644 index 00000000000..e67dd7d4e44 --- /dev/null +++ b/test/jasmine/assets/check_contrasting_text.js @@ -0,0 +1,65 @@ +'use strict'; + +const Plotly = require('../../../lib/index'); + +const Color = require('../../../src/components/color'); +const Lib = require('../../../src/lib'); +const d3SelectAll = require('../../strict-d3').selectAll; +const createGraphDiv = require('../assets/create_graph_div'); +const destroyGraphDiv = require('../assets/destroy_graph_div'); + +/** + * Check that inside text takes the more legible of the two default label colors. + * + * Traces that draw text on a filled shape pick the label color with + * `Color.contrast`, which returns either `Color.background` or + * `Color.defaultLine`. The rule that chooses between the two has changed more + * than once, and a change moves every trace type at the same time. + * + * The check states the property instead of the resulting color, so a change to + * the rule fails only where it makes text harder to read. `fillColor` must be + * the opaque color the trace paints behind the text, and it must sit near the + * point where the two label colors give similar contrast. A color far from that + * point passes under any plausible rule and tests nothing. + * + * Hierarchy roots draw outside text, which does not contrast against the slice. + * Give `tracePatch` a shape with no root when the trace type has a hierarchy. + * + * @param {String} traceType - trace type to plot + * @param {Object} tracePatch - trace attributes that produce inside text + * @param {String} textSelector - selector matching the drawn text nodes + * @param {String} fillColor - opaque color painted behind the text + */ +module.exports = function checkContrastingText(traceType, tracePatch, textSelector, fillColor) { + describe(`${traceType} default inside text color`, () => { + let gd; + + beforeEach(() => { + gd = createGraphDiv(); + }); + + afterEach(() => { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('should read at least as well as the other default label color', (done) => { + Plotly.newPlot(gd, [Lib.extendFlat({ type: traceType }, tracePatch)]) + .then(() => { + const nodes = d3SelectAll(textSelector); + expect(nodes.size()).toBeGreaterThan(0, `no text drawn for ${traceType}`); + + nodes.each(function (_, i) { + const { fill } = this.style; + const other = Color.equals(fill, Color.background) ? Color.defaultLine : Color.background; + + expect(Color.wcagContrast(fillColor, fill)).not.toBeLessThan( + Color.wcagContrast(fillColor, other), + `${traceType} element ${i}: ${fill} on ${fillColor}` + ); + }); + }) + .then(done, done.fail); + }); + }); +}; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 510ac8009e1..0d5b4045109 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -463,11 +463,11 @@ describe('Test axes', function() { yaxis: {} }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - var lightLine = Color.color(Color.lightLine).rgb().string(); + var lightLine = Color.rgbaString(Color.lightLine); expect(layoutOut.xaxis.gridwidth).toBe(1); - expect(Color.color(layoutOut.xaxis.gridcolor).rgb().string()).toBe(lightLine); + expect(Color.rgbaString(layoutOut.xaxis.gridcolor)).toBe(lightLine); expect(layoutOut.yaxis.gridwidth).toBe(1); - expect(Color.color(layoutOut.yaxis.gridcolor).rgb().string()).toBe(lightLine); + expect(Color.rgbaString(layoutOut.yaxis.gridcolor)).toBe(lightLine); }); it('should set gridcolor/gridwidth to undefined if showgrid is false', function() { @@ -567,6 +567,20 @@ describe('Test axes', function() { expect(layoutOut.yaxis2.gridcolor).toEqual(Color.mix('#444', bgColor, frac)); }); + // A translucent axis color is what exercises the rule: the channel weight + // scales by the alpha difference, so mixing toward an opaque background + // moves the channels less than a plain interpolation would. + it("should weight 'axis.gridcolor' channels by the alpha difference", () => { + layoutIn = { + paper_bgcolor: 'green', + plot_bgcolor: 'yellow', + xaxis: { showgrid: true, color: 'rgba(255, 0, 0, 0.5)' } + }; + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.xaxis.gridcolor).toEqual('rgba(255, 247, 0, 0.95)'); + }); + it('should default to a dark color for tickfont when plotting background is light', function() { layoutIn = { plot_bgcolor: 'lightblue', diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js index c90e15832b8..253b5a9717a 100644 --- a/test/jasmine/tests/bar_test.js +++ b/test/jasmine/tests/bar_test.js @@ -33,6 +33,7 @@ var d3Select = require('../../strict-d3').select; var d3SelectAll = require('../../strict-d3').selectAll; var BAR_TEXT_SELECTOR = '.bars .bartext'; +const checkContrastingText = require('../assets/check_contrasting_text'); describe('Bar.supplyDefaults', function() { 'use strict'; @@ -3353,3 +3354,15 @@ describe('bar uniformtext', function() { .then(done, done.fail); }); }); + +checkContrastingText( + 'bar', + { + y: [3, 2, 1], + text: ['A', 'B', 'C'], + textposition: 'inside', + marker: { color: '#3D9970' } + }, + BAR_TEXT_SELECTOR, + '#3D9970' +); diff --git a/test/jasmine/tests/color_test.js b/test/jasmine/tests/color_test.js index 28a2dc41bf6..8a25e43da78 100644 --- a/test/jasmine/tests/color_test.js +++ b/test/jasmine/tests/color_test.js @@ -1,13 +1,12 @@ var Color = require('../../../src/components/color'); - -describe('Test color:', function() { +describe('Test color:', function () { 'use strict'; - describe('fill', function() { - it('should call style with both fill and fill-opacity', function() { + describe('fill', function () { + it('should call style with both fill and fill-opacity', function () { var mockElement = { - style: function(object) { + style: function (object) { expect(object.fill).toBe('rgb(255, 255, 0)'); expect(object['fill-opacity']).toBe(0.5); } @@ -15,12 +14,21 @@ describe('Test color:', function() { Color.fill(mockElement, 'rgba(255,255,0,0.5)'); }); + + // Shapes and annotations leave the color unset when the user gives + // none, and the mark still has to show. + it('should paint opaque black when the color is missing', () => { + const seen = []; + Color.fill({ style: (o) => seen.push(o) }, undefined); // Mock the selection to track it's call + + expect(seen[0]).toEqual({ fill: 'rgb(0, 0, 0)', 'fill-opacity': 1 }); + }); }); - describe('stroke', function() { - it('should call style with both fill and fill-opacity', function() { + describe('stroke', function () { + it('should call style with both fill and fill-opacity', function () { var mockElement = { - style: function(object) { + style: function (object) { expect(object.stroke).toBe('rgb(255, 255, 0)'); expect(object['stroke-opacity']).toBe(0.5); } @@ -28,48 +36,265 @@ describe('Test color:', function() { Color.stroke(mockElement, 'rgba(255,255,0,0.5)'); }); + + it('should paint opaque black when the color is missing', () => { + const seen = []; + Color.stroke({ style: (o) => seen.push(o) }, undefined); // Mock the selection to track it's call + + expect(seen[0]).toEqual({ stroke: 'rgb(0, 0, 0)', 'stroke-opacity': 1 }); + }); }); describe('adjustLightness', () => { it('lightens with a positive delta (additive in HSL L percentage points)', () => { // black (L=0) + 50 → mid gray (L=50) - expect(Color.adjustLightness('#000', 50).hex()).toBe('#808080'); + expect(Color.hexString(Color.adjustLightness('#000', 50))).toBe('#808080'); }); it('darkens with a negative delta', () => { // white (L=100) - 50 → mid gray (L=50) - expect(Color.adjustLightness('#fff', -50).hex()).toBe('#808080'); + expect(Color.hexString(Color.adjustLightness('#fff', -50))).toBe('#808080'); }); it('shifts HSL lightness additively, not multiplicatively', () => { // additive: L 50.2 + 20 = 70.2 → #B3B3B3 // multiplicative would give L 50.2 * 1.2 = 60.2 → #9A9A9A - expect(Color.adjustLightness('#808080', 20).hex()).toBe('#B3B3B3'); + expect(Color.hexString(Color.adjustLightness('#808080', 20))).toBe('#B3B3B3'); }); it('preserves hue and saturation on chromatic colors', () => { // red (H=0, S=100, L=50) + 20 → HSL(0, 100, 70) → #FF6666 - expect(Color.adjustLightness('#ff0000', 20).hex()).toBe('#FF6666'); + expect(Color.hexString(Color.adjustLightness('#ff0000', 20))).toBe('#FF6666'); }); }); - describe('contrast', function() { - it('should darken light colors', function() { + describe('contrast', function () { + it('should darken light colors', function () { var out = Color.contrast('#eee', 10, 20); expect(out).toEqual('rgb(187, 187, 187)'); }); - it('should darken light colors (2)', function() { + it('should darken light colors (2)', function () { var out = Color.contrast('#fdae61', 10, 20); - expect(out).toEqual('rgb(245, 123, 3)'); + expect(out).toEqual('rgb(245, 122, 3)'); }); - it('should lighten dark colors', function() { + it('should lighten dark colors', function () { var out = Color.contrast('#2b83ba', 10, 20); expect(out).toEqual('rgb(68, 157, 212)'); }); + + // Colors whose two candidate labels sit close together. A luma + // approximation puts several of these on the wrong side. + it('picks the more legible label for near-threshold colors', () => { + ['rgb(0, 200, 0)', '#3D9970', '#FF4136', '#808080', '#4499FF', 'gray'].forEach((c) => { + const picked = Color.contrast(c); + const other = Color.equals(picked, Color.background) ? Color.defaultLine : Color.background; + + expect(Color.wcagContrast(c, picked)).not.toBeLessThan(Color.wcagContrast(c, other), c); + }); + }); + + it('flattens a translucent color onto the background first', () => { + expect(Color.contrast('rgba(0, 0, 0, 0.1)')).toBe('rgb(68, 68, 68)'); + }); + }); + + describe('isDark', () => { + it('reads saturated mid-tones by contrast, not by lightness', () => { + expect(Color.isDark('rgb(0, 200, 0)')).toBe(false); + expect(Color.isDark('#3D9970')).toBe(true); + expect(Color.isDark('#808080')).toBe(true); + }); + }); + + describe('parse', () => { + // Drawing code needs the alpha of a color it is about to paint, which is + // not the same question `opacity` answers. A color that is simply unset + // still gets painted, so it resolves to opaque black. + it('treats a missing color as opaque black, without warning', () => { + [undefined, null].forEach((v) => expect(Color.parse(v).alpha).toBe(1, String(v))); + }); + + it('treats an unparseable color as opaque black', () => { + ['', 'notacolor'].forEach((v) => expect(Color.parse(v).alpha).toBe(1, String(v))); + }); + + it('reads a real alpha channel', () => { + expect(Color.parse('rgba(255, 0, 0, 0.5)').alpha).toBe(0.5); + expect(Color.parse('transparent').alpha).toBe(0); + }); + + // `opacity` answers "is there a color here", so it keeps its own guard. + it('differs from opacity only for a missing color', () => { + [undefined, null, ''].forEach((v) => expect(Color.opacity(v)).toBe(0, String(v))); + expect(Color.opacity('notacolor')).toBe(1); + }); + }); + + describe('invalid input', () => { + const BAD = [undefined, null, 42, { r: 255, g: 0, b: 0 }, 'notacolor', '', '#gg0000']; + + it('falls back to opaque black instead of throwing', () => { + BAD.forEach((v) => expect(Color.rgbaString(v)).toBe('rgb(0, 0, 0)')); + }); + + it('reports the value as invalid', () => { + BAD.forEach((v) => expect(Color.isValid(v)).toBe(false)); + }); + + // Null channels used to reach the WebGL buffers through this path. + it('normalizes to four numeric channels', () => { + BAD.forEach((v) => expect(Color.normalize(v)).toEqual([0, 0, 0, 1])); + }); + }); + + // Per-point colors in the WebGL paths may be raw channels rather than a + // color string + describe('channel arrays', () => { + it('reads a 0-255 array', () => { + expect(Color.normalize([0, 255, 0])).toEqual([0, 1, 0, 1]); + expect(Color.normalize([128, 0, 0])).toEqual([128 / 255, 0, 0, 1]); + }); + + it('reads a typed array', () => { + expect(Color.normalize(new Uint8Array([255, 127, 0]))).toEqual([1, 127 / 255, 0, 1]); + }); + + it('leaves an already normalized array alone', () => { + expect(Color.normalize([1, 0, 0])).toEqual([1, 0, 0, 1]); + expect(Color.normalize([0.25, 0.75, 0.25, 1])).toEqual([0.25, 0.75, 0.25, 1]); + }); + + it('scales a 0-255 array that carries an alpha', () => { + expect(Color.normalize([64, 128, 192, 0.5])).toEqual([64 / 255, 128 / 255, 192 / 255, 0.5]); + expect(Color.normalize([255, 0, 0, 0.5])).toEqual([1, 0, 0, 0.5]); + }); + + it('reads alpha on whichever scale it arrives in', () => { + expect(Color.normalize(new Uint8Array([255, 127, 0, 128]))).toEqual([1, 127 / 255, 0, 128 / 255]); + expect(Color.normalize([255, 127, 0, 255])).toEqual([1, 127 / 255, 0, 1]); + expect(Color.normalize([255, 127, 0, 0.25])).toEqual([1, 127 / 255, 0, 0.25]); + }); + + it('keeps a fully transparent color transparent', () => { + expect(Color.normalize([1, 0, 0, 0])).toEqual([1, 0, 0, 0]); + expect(Color.normalize([255, 0, 0, 0])).toEqual([1, 0, 0, 0]); + }); + + it('honors the requested output type', () => { + expect(Color.normalize([0, 255, 0], 'uint8')).toEqual(Uint8Array.from([0, 255, 0, 255])); + }); + }); + + describe('isValid', () => { + it('accepts CSS Color 4 syntax', () => { + [ + 'oklch(0.7 0.15 180)', + 'lab(50% 40 59.5)', + 'color(display-p3 1 0 0)', + 'hwb(120 0% 0%)', + 'hsl(0.5turn 50% 50%)', + 'hsl(120 50% 50% / 50%)', + 'hsl(none 50% 50%)', + 'rgb(255 0 0 / 50%)' + ].forEach((s) => expect(Color.isValid(s)).toBe(true, s)); + }); + + it('rejects a mix of comma and space separators', () => { + expect(Color.isValid('hsl(120, 50% 50%)')).toBe(false); + }); + + it('rejects hsv, which is not CSS', () => { + expect(Color.isValid('hsv(120, 50%, 50%)')).toBe(false); + }); + }); + + describe('rounding', () => { + // #eee at -10 lightness is exactly 212.5 in 8 bits, but floating point + // makes it 212.49999999999997. + it('repairs float error before rounding a channel', () => { + expect(Color.adjustLightness('#eee', -10)).toBe('rgb(213, 213, 213)'); + expect(Color.rgbaArrayToString([53, 70.49999999999999, 208, 1])).toBe('rgb(53, 71, 208)'); + }); + + // #fdae61 at -20 lightness is a true 122.45, which must round down. + it('leaves a genuine fraction alone', () => { + expect(Color.contrast('#fdae61', 10, 20)).toBe('rgb(245, 122, 3)'); + }); + }); + + describe('mix', () => { + it('interpolates between two opaque colors', () => { + expect(Color.mix('red', 'blue', 50)).toBe('rgb(128, 0, 128)'); + expect(Color.mix('#444', 'white', 60)).toBe('rgb(180, 180, 180)'); + }); + + it('returns each end at the extremes', () => { + expect(Color.mix('red', 'blue', 0)).toBe('rgb(255, 0, 0)'); + expect(Color.mix('red', 'blue', 100)).toBe('rgb(0, 0, 255)'); + }); + + // Weighting the channels by the alpha difference keeps a mix toward a + // transparent color from dragging the channels toward its unused rgb. + it('shifts alpha without darkening when mixing toward transparent', () => { + expect(Color.mix('#444', 'rgba(0, 0, 0, 0)', 60)).toBe('rgba(68, 68, 68, 0.4)'); + }); + }); + + describe('combine', () => { + it('returns an opaque front unchanged', () => { + expect(Color.combine('red', '#fff')).toBe('rgb(255, 0, 0)'); + }); + + it('composites a translucent front onto the back', () => { + expect(Color.combine('rgba(255, 0, 0, 0.5)', '#fff')).toBe('rgb(255, 128, 128)'); + expect(Color.combine('rgba(255, 255, 255, 0.5)', '#000')).toBe('rgb(128, 128, 128)'); + }); + }); + + describe('clipping', () => { + it('clips a wide-gamut color into sRGB', () => { + expect(Color.rgbaArray('color(display-p3 1 0 0)')).toEqual([255, 0, 0, 1]); + expect(Color.normalize('color(display-p3 1 0 0)', 'uint8')).toEqual(Uint8Array.from([255, 0, 0, 255])); + }); + }); + + describe('known values', () => { + it('wcagContrast spans 1 to 21', () => { + expect(Color.wcagContrast('#fff', '#000')).toBe(21); + expect(Color.wcagContrast('red', 'red')).toBe(1); + }); + + it('reads alpha', () => { + expect(Color.opacity('rgba(255, 0, 0, 0.5)')).toBe(0.5); + expect(Color.opacity('red')).toBe(1); + expect(Color.opacity('transparent')).toBe(0); + }); + + it('sets alpha', () => { + expect(Color.addOpacity('red', 0.5)).toBe('rgba(255, 0, 0, 0.5)'); + expect(Color.addOpacity('rgba(255, 0, 0, 0.5)', 1)).toBe('rgb(255, 0, 0)'); + expect(Color.addOpacity('red', 2)).toBe('rgb(255, 0, 0)'); + }); + + it('formats as rgb and hex', () => { + expect(Color.rgbaString('red')).toBe('rgb(255, 0, 0)'); + expect(Color.rgbaString('rgba(255, 0, 0, 0.5)')).toBe('rgba(255, 0, 0, 0.5)'); + expect(Color.hexString('#1f77b4')).toBe('#1F77B4'); + }); + + it('compares colors across notations', () => { + expect(Color.equals('red', 'rgb(255, 0, 0)')).toBe(true); + expect(Color.equals('red', 'blue')).toBe(false); + }); + + it('converts to a 0-255 array', () => { + expect(Color.rgbaArray('rgba(255, 0, 0, 0.5)')).toEqual([255, 0, 0, 0.5]); + expect(Color.rgbaArrayToString([255, 0, 0, 0.5])).toBe('rgba(255, 0, 0, 0.5)'); + }); }); }); diff --git a/test/jasmine/tests/colorscale_test.js b/test/jasmine/tests/colorscale_test.js index 0ce9fbb6ad0..621b108b7a3 100644 --- a/test/jasmine/tests/colorscale_test.js +++ b/test/jasmine/tests/colorscale_test.js @@ -750,6 +750,30 @@ describe('Test colorscale restyle calls:', function() { return d3Select(q).node().style.fill; } + // A colorscale reaches the renderer as an array of channels, not as a color + // string. When that array fails to parse, every point falls back to the same + // black, which still renders and still passes the per-call unit tests. + it('should render a colorscale as distinct, non-black colors', (done) => { + Plotly.newPlot(gd, [{ + mode: 'markers', + y: [1, 2, 3, 4, 5], + marker: { color: [1, 2, 3, 4, 5], colorscale: 'Viridis' } + }]) + .then(() => { + const fills = []; + // d3 binds `this` to the node, so this callback stays a function. + d3SelectAll('path.point').each(function() { + fills.push(getFill(this)); + }); + + expect(fills.length).toBe(5); + expect(fills.every((f) => f === 'rgb(0, 0, 0)')).toBe(false, 'all points black'); + expect(new Set(fills).size).toBeGreaterThan(1, 'every point the same color'); + fills.forEach((f) => expect(f).toMatch(/^rgba?\(/, `not a usable color: ${f}`)); + }) + .then(done, done.fail); + }); + it('should be able to toggle between autocolorscale true/false and set colorscales (contour case)', function(done) { function _assert(msg, exp) { var cc = []; @@ -1045,8 +1069,8 @@ describe('Test colorscale restyle calls:', function() { .then(function() { _assert(msg + ' back to original marker.symbol', exp); }); } - var rdbu = ['rgb(5, 10, 172)', 'rgb(53, 71, 208)', 'rgb(227, 153, 104)', - 'rgb(53, 71, 208)', 'rgb(53, 71, 208)', 'rgb(178, 10, 28)']; + var rdbu = ['rgb(5, 10, 172)', 'rgb(53, 70, 208)', 'rgb(227, 153, 104)', + 'rgb(53, 70, 208)', 'rgb(53, 70, 208)', 'rgb(178, 10, 28)']; var grns = ['rgb(0, 68, 27)', 'rgb(12, 119, 52)', 'rgb(174, 222, 167)', 'rgb(12, 119, 52)', 'rgb(12, 119, 52)', 'rgb(247, 252, 245)']; diff --git a/test/jasmine/tests/gl_format_color_test.js b/test/jasmine/tests/gl_format_color_test.js new file mode 100644 index 00000000000..ee8c2f25a16 --- /dev/null +++ b/test/jasmine/tests/gl_format_color_test.js @@ -0,0 +1,135 @@ +const { formatColor, parseColorScale } = require('../../../src/lib/gl_format_color'); +const Color = require('../../../src/components/color'); + +describe('Test gl_format_color:', () => { + 'use strict'; + + // Every channel written here lands in a WebGL buffer. A null or a NaN + // reaches the shader as undefined behavior rather than as a visible + // wrong color, so the values matter more than the exact hue. + const expectUsableChannels = (rgba, msg) => { + expect(rgba.length).toBe(4, `${msg}: four channels`); + rgba.forEach((v, i) => { + expect(typeof v).toBe('number', `${msg}: channel ${i} is a number`); + expect(isFinite(v)).toBe(true, `${msg}: channel ${i} is finite`); + expect(v >= 0 && v <= 1).toBe(true, `${msg}: channel ${i} in [0, 1], got ${v}`); + }); + }; + + describe('formatColor', () => { + it('should convert a single color', () => { + expectUsableChannels(formatColor({ color: 'red' }, 1, 1), 'red'); + expect(formatColor({ color: 'red' }, 1, 1)).toEqual([1, 0, 0, 1]); + }); + + it('should apply opacity to the alpha channel', () => { + expect(formatColor({ color: 'red' }, 0.5, 1)).toEqual([1, 0, 0, 0.5]); + expect(formatColor({ color: 'rgba(255, 0, 0, 0.5)' }, 0.5, 1)).toEqual([1, 0, 0, 0.25]); + }); + + it('should fall back to the default line color for an unparseable color', () => { + const dflt = Color.normalize(Color.defaultLine); + + ['notacolor', '', undefined, null, {}].forEach((c) => { + expectUsableChannels(formatColor({ color: c }, 1, 1), String(c)); + expect(formatColor({ color: c }, 1, 1)).toEqual(dflt, String(c)); + }); + }); + + // A per-point color may be a color string, raw channels as a plain array + // or a typed array, or something unparseable + it('should resolve every element of an array color', () => { + const orange = new Uint8Array([255, 127, 0]); + const dflt = Color.normalize(Color.defaultLine); + const out = formatColor({ color: ['red', [0, 255, 0], 'rgba(0,0,255,0.5)', orange, 'notacolor'] }, 1, 5); + + expect(out.length).toBe(5); + expect(out[0]).toEqual([1, 0, 0, 1], 'red'); + expect(out[1]).toEqual([0, 1, 0, 1], '[0, 255, 0]'); + expect(out[2]).toEqual([0, 0, 1, 0.5], 'rgba(0,0,255,0.5)'); + expect(out[3]).toEqual([1, 127 / 255, 0, 1], 'Uint8Array orange'); + expect(out[4]).toEqual(dflt, 'notacolor'); + out.forEach((rgba, i) => expectUsableChannels(rgba, `point ${i}`)); + }); + + it('should write usable channels for a per-point colorscale', () => { + const out = formatColor( + { + color: [1, 2, 3], + colorscale: [ + [0, 'rgb(0, 0, 255)'], + [1, 'rgb(255, 0, 0)'] + ], + cmin: 1, + cmax: 3 + }, + 1, + 3 + ); + + expect(out.length).toBe(3); + out.forEach((rgba, i) => expectUsableChannels(rgba, `colorscale point ${i}`)); + expect(out[0]).not.toEqual(out[2], 'colorscale ends should differ'); + }); + + // Points with no color fall back to a module-level default array, and + // the opacity step multiplies the alpha of whatever it is handed. + it('should not carry alpha between points or between calls', () => { + const first = formatColor({ color: [undefined, undefined] }, 0.5, 2); + const second = formatColor({ color: [undefined, undefined] }, 0.5, 2); + + expect(first[0]).toEqual(first[1], 'the two points differ within one call'); + expect(first[0]).not.toBe(first[1], 'both points share one array'); + expect(second).toEqual(first, 'alpha decayed between calls'); + }); + + it('should clip a wide-gamut color into the buffer range', () => { + expectUsableChannels(formatColor({ color: 'color(display-p3 1 0 0)' }, 1, 1), 'display-p3'); + }); + + it('should fall back to the default line color for a numeric color in an array', () => { + const out = formatColor({ color: [42, 'red'] }, 1, 2); + + expect(out[0]).toEqual(Color.normalize(Color.defaultLine)); + expect(out[1]).toEqual([1, 0, 0, 1]); + }); + + it('should treat a scalar color the same as an array color', () => { + const dflt = Color.normalize(Color.defaultLine); + + expect(formatColor({ color: 42 }, 1, 1)).toEqual(dflt); + expect(formatColor({ color: [42, 42] }, 1, 2)[0]).toEqual(dflt); + }); + }); + + describe('parseColorScale', () => { + it('should return an index and a 0-255 rgba array per stop', () => { + const out = parseColorScale({ + colorscale: [ + [0, 'rgb(0, 0, 255)'], + [0.5, 'red'], + [1, 'rgb(0, 255, 0)'] + ] + }); + + expect(out.length).toBe(3); + expect(out[0].index).toBe(0); + expect(out[1].rgb).toEqual([255, 0, 0, 1]); + out.forEach((stop, i) => { + expect(stop.rgb.length).toBe(4, `stop ${i}`); + stop.rgb.forEach((v) => expect(isFinite(v)).toBe(true, `stop ${i} is finite`)); + }); + }); + + it('should honor reversescale', () => { + const colorscale = [ + [0, 'rgb(0, 0, 255)'], + [1, 'rgb(255, 0, 0)'] + ]; + const fwd = parseColorScale({ colorscale }); + const rev = parseColorScale({ colorscale, reversescale: true }); + + expect(rev[0].rgb).toEqual(fwd[fwd.length - 1].rgb); + }); + }); +}); diff --git a/test/jasmine/tests/pie_test.js b/test/jasmine/tests/pie_test.js index 5aad071dc2e..c2176782a47 100644 --- a/test/jasmine/tests/pie_test.js +++ b/test/jasmine/tests/pie_test.js @@ -21,6 +21,7 @@ const { font } = require('../../../src/plots/layout_attributes'); var SLICES_SELECTOR = '.slice path'; var SLICES_TEXT_SELECTOR = '.pielayer text.slicetext'; +const checkContrastingText = require('../assets/check_contrasting_text'); var LEGEND_ENTRIES_SELECTOR = '.legendpoints path'; describe('Pie defaults', function() { @@ -2086,3 +2087,15 @@ describe('pie value format', function() { .then(done, done.fail); }); }); + +checkContrastingText( + 'pie', + { + values: [3, 2, 1], + labels: ['A', 'B', 'C'], + textposition: 'inside', + marker: { colors: ['#3D9970', '#3D9970', '#3D9970'] } + }, + SLICES_TEXT_SELECTOR, + '#3D9970' +); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index d962aab7e1b..d71ea4a1e77 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -3251,3 +3251,52 @@ describe('plot_api edit_types', function () { expect(flags).toEqual({ calc: true, legend: true, style: true }); }); }); + +describe('color attribute coercion:', () => { + 'use strict'; + + let gd; + + beforeEach(() => { + gd = createGraphDiv(); + }); + + afterEach(() => { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + // Color attributes are gated by Color.isValid. A syntax the parser accepts + // but the gate rejects silently falls back to the default, which renders as + // a plausible color rather than as an error. + const CSS_COLOR_4 = [ + 'oklch(0.7 0.15 180)', + 'lab(50% 40 59.5)', + 'lch(50% 70 40)', + 'oklab(0.5 0.1 0.1)', + 'color(srgb 1 0 0)', + 'color(display-p3 1 0 0)', + 'hwb(120 0% 0%)', + 'hsl(0.5turn 50% 50%)', + 'hsl(120 50% 50% / 50%)', + 'rgb(255 0 0 / 50%)' + ]; + + CSS_COLOR_4.forEach((cstr) => { + it(`should keep ${cstr} through supplyDefaults`, (done) => { + Plotly.newPlot(gd, [{ y: [1, 2, 3], marker: { color: cstr } }]) + .then(() => { + expect(gd._fullData[0].marker.color).toBe(cstr, 'coerced away'); + }) + .then(done, done.fail); + }); + }); + + it('should drop an invalid color specifier', (done) => { + Plotly.newPlot(gd, [{ y: [1, 2, 3], marker: { color: 'notacolor' } }]) + .then(() => { + expect(gd._fullData[0].marker.color).not.toBe('notacolor'); + }) + .then(done, done.fail); + }); +}); diff --git a/test/jasmine/tests/sunburst_test.js b/test/jasmine/tests/sunburst_test.js index 3bc8492e039..e0fecd98105 100644 --- a/test/jasmine/tests/sunburst_test.js +++ b/test/jasmine/tests/sunburst_test.js @@ -19,6 +19,7 @@ var assertHoverLabelContent = customAssertions.assertHoverLabelContent; var checkTextTemplate = require('../assets/check_texttemplate'); var SLICES_TEXT_SELECTOR = '.sunburstlayer text.slicetext'; +const checkContrastingText = require('../assets/check_contrasting_text'); function _mouseEvent(type, gd, v) { return function () { @@ -3022,3 +3023,15 @@ describe('sunburst uniformtext', function () { .then(done, done.fail); }); }); + +checkContrastingText( + 'sunburst', + { + labels: ['A', 'B', 'C'], + parents: ['', '', ''], + values: [3, 2, 1], + marker: { colors: ['#3D9970', '#3D9970', '#3D9970'] } + }, + SLICES_TEXT_SELECTOR, + '#3D9970' +); diff --git a/test/jasmine/tests/waterfall_test.js b/test/jasmine/tests/waterfall_test.js index f6c2dcc9ded..06044a7883d 100644 --- a/test/jasmine/tests/waterfall_test.js +++ b/test/jasmine/tests/waterfall_test.js @@ -23,6 +23,7 @@ var d3Select = require('../../strict-d3').select; var d3SelectAll = require('../../strict-d3').selectAll; var WATERFALL_TEXT_SELECTOR = '.bars .bartext'; +const checkContrastingText = require('../assets/check_contrasting_text'); describe('Waterfall.supplyDefaults', function() { 'use strict'; @@ -1079,7 +1080,7 @@ describe('A waterfall plot', function() { insidetextfont: { family: ['"comic sans"', 'arial', 'arial'], // NOTE: 'color' does NOT inherit from 'textfont.color' since 'color' should be contrasting to bar's fill by default. - color: ['black', 'green', Color.contrast('white')], + color: ['black', 'green', 'white'], size: [8, 12, 16] }, outsidetextfont: { @@ -1184,7 +1185,7 @@ describe('A waterfall plot', function() { text: ['A', 'B', 'C'], textposition: 'inside' }]) - .then(assertTextFontColors(['rgb(68, 68, 68)', 'rgb(68, 68, 68)', 'rgb(68, 68, 68)'])) + .then(assertTextFontColors(['rgb(255, 255, 255)', 'rgb(255, 255, 255)', 'rgb(255, 255, 255)'])) .then(function() { gd.data[0].insidetextfont = {color: 'red'}; return Plotly.react(gd, gd.data); @@ -1921,3 +1922,17 @@ describe('waterfall uniformtext', function() { .then(done, done.fail); }); }); + +checkContrastingText( + 'waterfall', + { + y: [3, 2, 1], + text: ['A', 'B', 'C'], + textposition: 'inside', + increasing: { marker: { color: '#3D9970' } }, + decreasing: { marker: { color: '#3D9970' } }, + totals: { marker: { color: '#3D9970' } } + }, + WATERFALL_TEXT_SELECTOR, + '#3D9970' +);