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'
+);