@configuratorware/configurator-frontendgui 1.57.10 → 1.57.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1907,6 +1907,11 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
1907
1907
  state,
1908
1908
  showHighlight,
1909
1909
  staticBorderColor,
1910
+ dynamicDesignAreaHighlightBorderColor,
1911
+ dynamicDesignAreaHighlightBorderColorMode,
1912
+ getDesignAreaUnderlyingColor,
1913
+ allDesignAreasForBorderColor,
1914
+ borderColor,
1910
1915
  textureBuilderConfig,
1911
1916
  variantIdentifier,
1912
1917
  effectEnabled,
@@ -1919,11 +1924,16 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
1919
1924
  state = _ServiceLocator.Services.store.state;
1920
1925
  showHighlight = (0, _get2["default"])(state, 'designArea.showHighlight');
1921
1926
  staticBorderColor = (0, _Selectors3.getClientHighlightColor)(state);
1927
+ dynamicDesignAreaHighlightBorderColor = (0, _configuration.getConf)('visualization.dynamicDesignAreaHighlightBorderColor');
1928
+ dynamicDesignAreaHighlightBorderColorMode = (0, _configuration.getConf)('visualization.dynamicDesignAreaHighlightBorderColorMode');
1929
+ getDesignAreaUnderlyingColor = function getDesignAreaUnderlyingColor(identifier) {
1930
+ return _ServiceLocator.Services.visualization.getDesignAreaUnderlyingColor(identifier);
1931
+ }; // 2D invokes drawingFn once per area with a single-element list; unify borders from the full view.
1932
+ allDesignAreasForBorderColor = this.getDesignAreas();
1933
+ borderColor = dynamicDesignAreaHighlightBorderColor ? (0, _getDesignAreaBorderColor.createBorderColorResolver)(getDesignAreaUnderlyingColor, staticBorderColor, dynamicDesignAreaHighlightBorderColorMode, allDesignAreasForBorderColor) : staticBorderColor;
1922
1934
  textureBuilderConfig = (0, _merge["default"])({
1923
1935
  highlight: {
1924
- borderColor: (0, _configuration.getConf)('visualization.dynamicDesignAreaHighlightBorderColor') ? (0, _getDesignAreaBorderColor.createBorderColorResolver)(function (identifier) {
1925
- return _ServiceLocator.Services.visualization.getDesignAreaUnderlyingColor(identifier);
1926
- }, staticBorderColor, (0, _configuration.getConf)('visualization.dynamicDesignAreaHighlightBorderColorMode')) : staticBorderColor,
1936
+ borderColor: borderColor,
1927
1937
  backgroundColor: (0, _configuration.getConf)('visualization.designAreaHighlightBackgroundColor'),
1928
1938
  lineWidth: (0, _configuration.getConf)('visualization.designAreaHighlightBorderWidth', 2)
1929
1939
  }
@@ -2119,22 +2129,22 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
2119
2129
  return _ref17.apply(this, arguments);
2120
2130
  };
2121
2131
  }();
2122
- _context22.prev = 8;
2123
- _context22.next = 11;
2124
- return getDrawingContext(drawingFn, true /* update alpha */, true /* clean up texture before drawing */);
2125
- case 11:
2132
+ _context22.prev = 13;
2126
2133
  _context22.next = 16;
2134
+ return getDrawingContext(drawingFn, true /* update alpha */, true /* clean up texture before drawing */);
2135
+ case 16:
2136
+ _context22.next = 21;
2127
2137
  break;
2128
- case 13:
2129
- _context22.prev = 13;
2130
- _context22.t0 = _context22["catch"](8);
2138
+ case 18:
2139
+ _context22.prev = 18;
2140
+ _context22.t0 = _context22["catch"](13);
2131
2141
  // eslint-disable-next-line no-console
2132
2142
  console.warn('Could not get drawing context, texture update is skipped!', _context22.t0.message);
2133
- case 16:
2143
+ case 21:
2134
2144
  case "end":
2135
2145
  return _context22.stop();
2136
2146
  }
2137
- }, _callee21, null, [[8, 13]]);
2147
+ }, _callee21, this, [[13, 18]]);
2138
2148
  }));
2139
2149
  function redrawAllDesignAreas(_x20, _x21) {
2140
2150
  return _redrawAllDesignAreas.apply(this, arguments);
@@ -3,9 +3,16 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.rgbToHsl = exports.invertColor = exports.getContrastBorderColor = exports.getComplementaryBorderColor = exports.getBorderColorForAverage = exports.createBorderColorResolver = exports.BorderColorModes = void 0;
6
+ exports.rgbToHsl = exports.invertColor = exports.getContrastBorderColor = exports.getComplementaryBorderColor = exports.getBorderColorForAverage = exports.createBorderColorResolver = exports.computeUnifiedContrastBorders = exports.BorderColorModes = void 0;
7
7
  var _styles = require("@material-ui/core/styles");
8
8
  var _Selectors = require("../Reducers/DesignArea/Selectors");
9
+ function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t["return"] || t["return"](); } finally { if (u) throw o; } } }; }
10
+ function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); }
11
+ function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
12
+ function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
13
+ function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); }
14
+ function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); }
15
+ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
9
16
  /**
10
17
  * Minimum contrast between the border and the area it is drawn on, the WCAG AA requirement for
11
18
  * graphical objects. Below it the derived color is not considered visible enough.
@@ -112,6 +119,135 @@ var getContrastBorderColor = exports.getContrastBorderColor = function getContra
112
119
  return (0, _styles.getContrastRatio)(averageHex, NEUTRAL_DARK) >= (0, _styles.getContrastRatio)(averageHex, NEUTRAL_LIGHT) ? NEUTRAL_DARK : NEUTRAL_LIGHT;
113
120
  };
114
121
 
122
+ /**
123
+ * @param {string} neutral
124
+ * @param {Array<{averageHex: string}>} areas
125
+ * @return {number}
126
+ */
127
+ var minContrastForNeutralAcrossAreas = function minContrastForNeutralAcrossAreas(neutral, areas) {
128
+ return Math.min.apply(Math, _toConsumableArray(areas.map(function (_ref3) {
129
+ var averageHex = _ref3.averageHex;
130
+ return (0, _styles.getContrastRatio)(averageHex, neutral);
131
+ })));
132
+ };
133
+
134
+ /**
135
+ * @param {string} neutral
136
+ * @param {Array<{averageHex: string}>} areas
137
+ * @return {boolean}
138
+ */
139
+ var neutralMeetsMinContrastOnAllAreas = function neutralMeetsMinContrastOnAllAreas(neutral, areas) {
140
+ return minContrastForNeutralAcrossAreas(neutral, areas) >= MIN_CONTRAST_RATIO;
141
+ };
142
+
143
+ /**
144
+ * Picks one near-black or near-white border for every design area in a highlight pass when possible,
145
+ * so similar product shades do not flip border color at the luminance tie-break.
146
+ * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
147
+ * @param {Array<{identifier: string}>} designAreas
148
+ * @return {Map<string, string>}
149
+ */
150
+ var computeUnifiedContrastBorders = exports.computeUnifiedContrastBorders = function computeUnifiedContrastBorders(getUnderlyingColor, designAreas) {
151
+ var areas = [];
152
+ var _iterator = _createForOfIteratorHelper(designAreas),
153
+ _step;
154
+ try {
155
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
156
+ var designArea = _step.value;
157
+ var _identifier3 = designArea && designArea.identifier;
158
+ if (!_identifier3) {
159
+ continue;
160
+ }
161
+ var average = getUnderlyingColor(_identifier3);
162
+ if (!average) {
163
+ continue;
164
+ }
165
+ areas.push({
166
+ identifier: _identifier3,
167
+ averageHex: (0, _Selectors.rgbToHex)(average.r, average.g, average.b)
168
+ });
169
+ }
170
+ } catch (err) {
171
+ _iterator.e(err);
172
+ } finally {
173
+ _iterator.f();
174
+ }
175
+ var borderByIdentifier = new Map();
176
+ if (!areas.length) {
177
+ return borderByIdentifier;
178
+ }
179
+ var darkVotes = 0;
180
+ var lightVotes = 0;
181
+ for (var _i = 0, _areas = areas; _i < _areas.length; _i++) {
182
+ var averageHex = _areas[_i].averageHex;
183
+ var vote = getContrastBorderColor(averageHex);
184
+ if (vote === NEUTRAL_DARK) {
185
+ darkVotes += 1;
186
+ } else {
187
+ lightVotes += 1;
188
+ }
189
+ }
190
+ var majorityNeutral;
191
+ if (darkVotes > lightVotes) {
192
+ majorityNeutral = NEUTRAL_DARK;
193
+ } else if (lightVotes > darkVotes) {
194
+ majorityNeutral = NEUTRAL_LIGHT;
195
+ }
196
+ if (majorityNeutral && neutralMeetsMinContrastOnAllAreas(majorityNeutral, areas)) {
197
+ var _iterator2 = _createForOfIteratorHelper(areas),
198
+ _step2;
199
+ try {
200
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
201
+ var identifier = _step2.value.identifier;
202
+ borderByIdentifier.set(identifier, majorityNeutral);
203
+ }
204
+ } catch (err) {
205
+ _iterator2.e(err);
206
+ } finally {
207
+ _iterator2.f();
208
+ }
209
+ return borderByIdentifier;
210
+ }
211
+ var qualifyingNeutrals = [NEUTRAL_DARK, NEUTRAL_LIGHT].filter(function (neutral) {
212
+ return neutralMeetsMinContrastOnAllAreas(neutral, areas);
213
+ });
214
+ if (qualifyingNeutrals.length) {
215
+ var maximinNeutral = qualifyingNeutrals.reduce(function (best, neutral) {
216
+ var minContrast = minContrastForNeutralAcrossAreas(neutral, areas);
217
+ if (!best) {
218
+ return {
219
+ neutral: neutral,
220
+ minContrast: minContrast
221
+ };
222
+ }
223
+ return minContrast > best.minContrast ? {
224
+ neutral: neutral,
225
+ minContrast: minContrast
226
+ } : best;
227
+ }, null).neutral;
228
+ var _iterator3 = _createForOfIteratorHelper(areas),
229
+ _step3;
230
+ try {
231
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
232
+ var _identifier = _step3.value.identifier;
233
+ borderByIdentifier.set(_identifier, maximinNeutral);
234
+ }
235
+ } catch (err) {
236
+ _iterator3.e(err);
237
+ } finally {
238
+ _iterator3.f();
239
+ }
240
+ return borderByIdentifier;
241
+ }
242
+ for (var _i2 = 0, _areas2 = areas; _i2 < _areas2.length; _i2++) {
243
+ var _areas2$_i = _areas2[_i2],
244
+ _identifier2 = _areas2$_i.identifier,
245
+ _averageHex = _areas2$_i.averageHex;
246
+ borderByIdentifier.set(_identifier2, getContrastBorderColor(_averageHex));
247
+ }
248
+ return borderByIdentifier;
249
+ };
250
+
115
251
  /**
116
252
  * Keeps the hue of the inverted (complementary) color at a high saturation and moves the lightness
117
253
  * away from the average, only as far as needed to reach a visible contrast, so the border is a vivid
@@ -128,8 +264,8 @@ var getComplementaryBorderColor = exports.getComplementaryBorderColor = function
128
264
  saturation = _rgbToHsl.saturation;
129
265
  if (saturation >= MIN_SATURATION) {
130
266
  var lightnessCandidates = (0, _styles.getLuminance)(averageHex) > LIGHT_AVERAGE_LUMINANCE ? LIGHTNESS_ON_LIGHT_AVERAGE : LIGHTNESS_ON_DARK_AVERAGE;
131
- for (var _i = 0, _lightnessCandidates = lightnessCandidates; _i < _lightnessCandidates.length; _i++) {
132
- var lightness = _lightnessCandidates[_i];
267
+ for (var _i3 = 0, _lightnessCandidates = lightnessCandidates; _i3 < _lightnessCandidates.length; _i3++) {
268
+ var lightness = _lightnessCandidates[_i3];
133
269
  var candidate = (0, _Selectors.rgbString2hex)((0, _styles.hslToRgb)("hsl(".concat(Math.round(hue), ", ").concat(BORDER_SATURATION, "%, ").concat(lightness, "%)")));
134
270
  if (candidate && (0, _styles.getContrastRatio)(averageHex, candidate) >= MIN_CONTRAST_RATIO) {
135
271
  return candidate;
@@ -160,11 +296,31 @@ var getBorderColorForAverage = exports.getBorderColorForAverage = function getBo
160
296
  * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
161
297
  * @param {string} fallbackColor
162
298
  * @param {string} [mode] One of BorderColorModes
299
+ * @param {Array<{identifier: string}>} [designAreas] When set in contrast mode, one border is chosen for the pass
163
300
  * @return {function(object): string}
164
301
  */
165
- var createBorderColorResolver = exports.createBorderColorResolver = function createBorderColorResolver(getUnderlyingColor, fallbackColor, mode) {
302
+ var createBorderColorResolver = exports.createBorderColorResolver = function createBorderColorResolver(getUnderlyingColor, fallbackColor, mode, designAreas) {
303
+ var useUnifiedContrast = mode !== BorderColorModes.Complementary && designAreas && designAreas.length;
304
+ var unifiedBorderByIdentifier = null;
305
+ var getUnifiedBorderByIdentifier = function getUnifiedBorderByIdentifier() {
306
+ if (!useUnifiedContrast) {
307
+ return null;
308
+ }
309
+ var sampledCount = designAreas.filter(function (designArea) {
310
+ return (designArea === null || designArea === void 0 ? void 0 : designArea.identifier) && getUnderlyingColor(designArea.identifier);
311
+ }).length;
312
+ if (!unifiedBorderByIdentifier || sampledCount > 0 && unifiedBorderByIdentifier.size < sampledCount) {
313
+ unifiedBorderByIdentifier = computeUnifiedContrastBorders(getUnderlyingColor, designAreas);
314
+ }
315
+ return unifiedBorderByIdentifier;
316
+ };
166
317
  return function (designArea) {
167
- var average = getUnderlyingColor(designArea && designArea.identifier);
318
+ var identifier = designArea && designArea.identifier;
319
+ var unified = getUnifiedBorderByIdentifier();
320
+ if (unified && identifier && unified.has(identifier)) {
321
+ return unified.get(identifier);
322
+ }
323
+ var average = getUnderlyingColor(identifier);
168
324
  return average ? getBorderColorForAverage(average, mode) : fallbackColor;
169
325
  };
170
326
  };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@configuratorware/configurator-frontendgui",
3
- "version": "1.57.10",
3
+ "version": "1.57.11",
4
4
  "license": "UNLICENSED",
5
5
  "private": false,
6
6
  "main": "./index.js",
7
7
  "dependencies": {
8
8
  "@babel/polyfill": "^7.12.1",
9
- "@configuratorware/scripts": "1.57.10",
9
+ "@configuratorware/scripts": "1.57.11",
10
10
  "@material-ui/core": "^4.12.4",
11
11
  "@material-ui/icons": "^4.11.3",
12
12
  "@material-ui/lab": "^4.0.0-alpha.61",
@@ -36,8 +36,8 @@
36
36
  "react-router-dom": "^5.3.4",
37
37
  "react-swipeable": "^5.5.1",
38
38
  "react-zoom-pan-pinch": "^2.1.3",
39
- "redhotmagma-graphics-editor": "1.57.10",
40
- "redhotmagma-visualization": "1.57.10",
39
+ "redhotmagma-graphics-editor": "1.57.11",
40
+ "redhotmagma-visualization": "1.57.11",
41
41
  "redux": "^4.1.0",
42
42
  "redux-logger": "^3.0.6",
43
43
  "redux-persist": "^5.10.0",
@@ -64,7 +64,7 @@
64
64
  "css-loader": "^5.2.7",
65
65
  "file-loader": "^6.2.0",
66
66
  "mini-css-extract-plugin": "^2.7.6",
67
- "node-sass": "^9.0.0",
67
+ "sass": "1.32.0",
68
68
  "react": "^18.2.0",
69
69
  "react-dom": "^18.2.0",
70
70
  "react-refresh": "^0.14.0",
@@ -1604,16 +1604,28 @@ export default class DesignDataService {
1604
1604
  const showHighlight = get(state, 'designArea.showHighlight');
1605
1605
 
1606
1606
  const staticBorderColor = getClientHighlightColor(state);
1607
+ const dynamicDesignAreaHighlightBorderColor = getConf(
1608
+ 'visualization.dynamicDesignAreaHighlightBorderColor'
1609
+ );
1610
+ const dynamicDesignAreaHighlightBorderColorMode = getConf(
1611
+ 'visualization.dynamicDesignAreaHighlightBorderColorMode'
1612
+ );
1613
+ const getDesignAreaUnderlyingColor = identifier =>
1614
+ Services.visualization.getDesignAreaUnderlyingColor(identifier);
1615
+ // 2D invokes drawingFn once per area with a single-element list; unify borders from the full view.
1616
+ const allDesignAreasForBorderColor = this.getDesignAreas();
1617
+ const borderColor = dynamicDesignAreaHighlightBorderColor
1618
+ ? createBorderColorResolver(
1619
+ getDesignAreaUnderlyingColor,
1620
+ staticBorderColor,
1621
+ dynamicDesignAreaHighlightBorderColorMode,
1622
+ allDesignAreasForBorderColor
1623
+ )
1624
+ : staticBorderColor;
1607
1625
  const textureBuilderConfig = merge(
1608
1626
  {
1609
1627
  highlight: {
1610
- borderColor: getConf('visualization.dynamicDesignAreaHighlightBorderColor')
1611
- ? createBorderColorResolver(
1612
- identifier => Services.visualization.getDesignAreaUnderlyingColor(identifier),
1613
- staticBorderColor,
1614
- getConf('visualization.dynamicDesignAreaHighlightBorderColorMode')
1615
- )
1616
- : staticBorderColor,
1628
+ borderColor,
1617
1629
  backgroundColor: getConf('visualization.designAreaHighlightBackgroundColor'),
1618
1630
  lineWidth: getConf('visualization.designAreaHighlightBorderWidth', 2),
1619
1631
  },
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  BorderColorModes,
3
+ computeUnifiedContrastBorders,
3
4
  createBorderColorResolver,
4
5
  getBorderColorForAverage,
6
+ getContrastBorderColor,
5
7
  invertColor,
6
8
  rgbToHsl,
7
9
  } from '../getDesignAreaBorderColor';
@@ -96,6 +98,70 @@ describe('getBorderColorForAverage in complementary mode', () => {
96
98
  });
97
99
  });
98
100
 
101
+ const grey = value => ({ r: value, g: value, b: value });
102
+
103
+ describe('computeUnifiedContrastBorders', () => {
104
+ test('uses one border for similar greys that would flip individually', () => {
105
+ const colors = {
106
+ left: grey(100),
107
+ right: grey(120),
108
+ };
109
+ const borders = computeUnifiedContrastBorders(id => colors[id], [
110
+ { identifier: 'left' },
111
+ { identifier: 'right' },
112
+ ]);
113
+
114
+ expect(getContrastBorderColor('#646464')).toBe('#eeeeee');
115
+ expect(getContrastBorderColor('#787878')).toBe('#111111');
116
+ expect(borders.get('left')).toBe(borders.get('right'));
117
+ });
118
+
119
+ test('uses majority neutral when it is visible on every area', () => {
120
+ const colors = {
121
+ a: grey(50),
122
+ b: grey(60),
123
+ c: grey(128),
124
+ };
125
+ const borders = computeUnifiedContrastBorders(id => colors[id], [
126
+ { identifier: 'a' },
127
+ { identifier: 'b' },
128
+ { identifier: 'c' },
129
+ ]);
130
+
131
+ expect(borders.get('a')).toBe('#eeeeee');
132
+ expect(borders.get('b')).toBe('#eeeeee');
133
+ expect(borders.get('c')).toBe('#eeeeee');
134
+ });
135
+
136
+ test('falls back to maximin when majority neutral fails on an area', () => {
137
+ const colors = {
138
+ dark: grey(30),
139
+ mid: grey(128),
140
+ };
141
+ const borders = computeUnifiedContrastBorders(id => colors[id], [
142
+ { identifier: 'dark' },
143
+ { identifier: 'mid' },
144
+ ]);
145
+
146
+ expect(borders.get('dark')).toBe('#eeeeee');
147
+ expect(borders.get('mid')).toBe('#eeeeee');
148
+ });
149
+
150
+ test('allows per-area colors when no single neutral works everywhere', () => {
151
+ const colors = {
152
+ light: { r: 255, g: 255, b: 255 },
153
+ dark: { r: 0, g: 0, b: 0 },
154
+ };
155
+ const borders = computeUnifiedContrastBorders(id => colors[id], [
156
+ { identifier: 'light' },
157
+ { identifier: 'dark' },
158
+ ]);
159
+
160
+ expect(borders.get('light')).toBe('#111111');
161
+ expect(borders.get('dark')).toBe('#eeeeee');
162
+ });
163
+ });
164
+
99
165
  describe('createBorderColorResolver', () => {
100
166
  test('resolves the border color from the underlying color of the design area', () => {
101
167
  const getUnderlyingColor = jest.fn().mockReturnValue(JACKET_GREEN);
@@ -125,4 +191,75 @@ describe('createBorderColorResolver', () => {
125
191
  test('falls back to the given color without a design area', () => {
126
192
  expect(createBorderColorResolver(() => null, '#abcdef')()).toBe('#abcdef');
127
193
  });
194
+
195
+ test('unifies contrast borders across design areas when the list is given', () => {
196
+ const colors = {
197
+ left: grey(100),
198
+ right: grey(120),
199
+ };
200
+ const resolve = createBorderColorResolver(id => colors[id], '#abcdef', BorderColorModes.Contrast, [
201
+ { identifier: 'left' },
202
+ { identifier: 'right' },
203
+ ]);
204
+
205
+ expect(resolve({ identifier: 'left' })).toBe(resolve({ identifier: 'right' }));
206
+ });
207
+
208
+ test('unifies when resolved one area at a time like 2D drawing passes', () => {
209
+ const colors = {
210
+ chestLeft: grey(100),
211
+ chestRight: grey(120),
212
+ collar: grey(115),
213
+ };
214
+ const allAreas = [
215
+ { identifier: 'chestLeft' },
216
+ { identifier: 'chestRight' },
217
+ { identifier: 'collar' },
218
+ ];
219
+ const resolve = createBorderColorResolver(
220
+ id => colors[id],
221
+ '#abcdef',
222
+ BorderColorModes.Contrast,
223
+ allAreas
224
+ );
225
+
226
+ const chestLeftBorder = resolve({ identifier: 'chestLeft' });
227
+ const chestRightBorder = resolve({ identifier: 'chestRight' });
228
+ const collarBorder = resolve({ identifier: 'collar' });
229
+
230
+ expect(chestLeftBorder).toBe(chestRightBorder);
231
+ expect(chestRightBorder).toBe(collarBorder);
232
+ });
233
+
234
+ test('resolves per area when design areas are omitted', () => {
235
+ const resolve = createBorderColorResolver(
236
+ id => (id === 'left' ? grey(100) : grey(120)),
237
+ '#abcdef',
238
+ BorderColorModes.Contrast
239
+ );
240
+
241
+ expect(resolve({ identifier: 'left' })).toBe('#eeeeee');
242
+ expect(resolve({ identifier: 'right' })).toBe('#111111');
243
+ });
244
+
245
+ test('does not unify borders in complementary mode', () => {
246
+ const colors = {
247
+ left: grey(100),
248
+ right: grey(120),
249
+ };
250
+ const resolve = createBorderColorResolver(
251
+ id => colors[id],
252
+ '#abcdef',
253
+ BorderColorModes.Complementary,
254
+ [{ identifier: 'left' }, { identifier: 'right' }]
255
+ );
256
+
257
+ expect(resolve({ identifier: 'left' })).toBe(
258
+ getBorderColorForAverage(colors.left, BorderColorModes.Complementary)
259
+ );
260
+ expect(resolve({ identifier: 'right' })).toBe(
261
+ getBorderColorForAverage(colors.right, BorderColorModes.Complementary)
262
+ );
263
+ expect(resolve({ identifier: 'left' })).not.toBe(resolve({ identifier: 'right' }));
264
+ });
128
265
  });
@@ -95,6 +95,105 @@ export const getContrastBorderColor = averageHex =>
95
95
  ? NEUTRAL_DARK
96
96
  : NEUTRAL_LIGHT;
97
97
 
98
+ /**
99
+ * @param {string} neutral
100
+ * @param {Array<{averageHex: string}>} areas
101
+ * @return {number}
102
+ */
103
+ const minContrastForNeutralAcrossAreas = (neutral, areas) =>
104
+ Math.min(...areas.map(({ averageHex }) => getContrastRatio(averageHex, neutral)));
105
+
106
+ /**
107
+ * @param {string} neutral
108
+ * @param {Array<{averageHex: string}>} areas
109
+ * @return {boolean}
110
+ */
111
+ const neutralMeetsMinContrastOnAllAreas = (neutral, areas) =>
112
+ minContrastForNeutralAcrossAreas(neutral, areas) >= MIN_CONTRAST_RATIO;
113
+
114
+ /**
115
+ * Picks one near-black or near-white border for every design area in a highlight pass when possible,
116
+ * so similar product shades do not flip border color at the luminance tie-break.
117
+ * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
118
+ * @param {Array<{identifier: string}>} designAreas
119
+ * @return {Map<string, string>}
120
+ */
121
+ export const computeUnifiedContrastBorders = (getUnderlyingColor, designAreas) => {
122
+ const areas = [];
123
+
124
+ for (const designArea of designAreas) {
125
+ const identifier = designArea && designArea.identifier;
126
+ if (!identifier) {
127
+ continue;
128
+ }
129
+ const average = getUnderlyingColor(identifier);
130
+ if (!average) {
131
+ continue;
132
+ }
133
+ areas.push({
134
+ identifier,
135
+ averageHex: rgbToHex(average.r, average.g, average.b),
136
+ });
137
+ }
138
+
139
+ const borderByIdentifier = new Map();
140
+
141
+ if (!areas.length) {
142
+ return borderByIdentifier;
143
+ }
144
+
145
+ let darkVotes = 0;
146
+ let lightVotes = 0;
147
+
148
+ for (const { averageHex } of areas) {
149
+ const vote = getContrastBorderColor(averageHex);
150
+ if (vote === NEUTRAL_DARK) {
151
+ darkVotes += 1;
152
+ } else {
153
+ lightVotes += 1;
154
+ }
155
+ }
156
+
157
+ let majorityNeutral;
158
+ if (darkVotes > lightVotes) {
159
+ majorityNeutral = NEUTRAL_DARK;
160
+ } else if (lightVotes > darkVotes) {
161
+ majorityNeutral = NEUTRAL_LIGHT;
162
+ }
163
+
164
+ if (majorityNeutral && neutralMeetsMinContrastOnAllAreas(majorityNeutral, areas)) {
165
+ for (const { identifier } of areas) {
166
+ borderByIdentifier.set(identifier, majorityNeutral);
167
+ }
168
+ return borderByIdentifier;
169
+ }
170
+
171
+ const qualifyingNeutrals = [NEUTRAL_DARK, NEUTRAL_LIGHT].filter(neutral =>
172
+ neutralMeetsMinContrastOnAllAreas(neutral, areas)
173
+ );
174
+
175
+ if (qualifyingNeutrals.length) {
176
+ const maximinNeutral = qualifyingNeutrals.reduce((best, neutral) => {
177
+ const minContrast = minContrastForNeutralAcrossAreas(neutral, areas);
178
+ if (!best) {
179
+ return { neutral, minContrast };
180
+ }
181
+ return minContrast > best.minContrast ? { neutral, minContrast } : best;
182
+ }, null).neutral;
183
+
184
+ for (const { identifier } of areas) {
185
+ borderByIdentifier.set(identifier, maximinNeutral);
186
+ }
187
+ return borderByIdentifier;
188
+ }
189
+
190
+ for (const { identifier, averageHex } of areas) {
191
+ borderByIdentifier.set(identifier, getContrastBorderColor(averageHex));
192
+ }
193
+
194
+ return borderByIdentifier;
195
+ };
196
+
98
197
  /**
99
198
  * Keeps the hue of the inverted (complementary) color at a high saturation and moves the lightness
100
199
  * away from the average, only as far as needed to reach a visible contrast, so the border is a vivid
@@ -150,9 +249,38 @@ export const getBorderColorForAverage = (average, mode = BorderColorModes.Contra
150
249
  * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
151
250
  * @param {string} fallbackColor
152
251
  * @param {string} [mode] One of BorderColorModes
252
+ * @param {Array<{identifier: string}>} [designAreas] When set in contrast mode, one border is chosen for the pass
153
253
  * @return {function(object): string}
154
254
  */
155
- export const createBorderColorResolver = (getUnderlyingColor, fallbackColor, mode) => designArea => {
156
- const average = getUnderlyingColor(designArea && designArea.identifier);
157
- return average ? getBorderColorForAverage(average, mode) : fallbackColor;
255
+ export const createBorderColorResolver = (getUnderlyingColor, fallbackColor, mode, designAreas) => {
256
+ const useUnifiedContrast = mode !== BorderColorModes.Complementary && designAreas && designAreas.length;
257
+ let unifiedBorderByIdentifier = null;
258
+
259
+ const getUnifiedBorderByIdentifier = () => {
260
+ if (!useUnifiedContrast) {
261
+ return null;
262
+ }
263
+ const sampledCount = designAreas.filter(
264
+ designArea => designArea?.identifier && getUnderlyingColor(designArea.identifier)
265
+ ).length;
266
+ if (
267
+ !unifiedBorderByIdentifier ||
268
+ (sampledCount > 0 && unifiedBorderByIdentifier.size < sampledCount)
269
+ ) {
270
+ unifiedBorderByIdentifier = computeUnifiedContrastBorders(getUnderlyingColor, designAreas);
271
+ }
272
+ return unifiedBorderByIdentifier;
273
+ };
274
+
275
+ return designArea => {
276
+ const identifier = designArea && designArea.identifier;
277
+ const unified = getUnifiedBorderByIdentifier();
278
+
279
+ if (unified && identifier && unified.has(identifier)) {
280
+ return unified.get(identifier);
281
+ }
282
+
283
+ const average = getUnderlyingColor(identifier);
284
+ return average ? getBorderColorForAverage(average, mode) : fallbackColor;
285
+ };
158
286
  };