@configuratorware/configurator-frontendgui 1.57.6 → 1.57.7

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.
@@ -26,6 +26,7 @@ var _Actions4 = require("../Reducers/DesignView/Actions");
26
26
  var _Selectors = require("../Reducers/DesignArea/Selectors");
27
27
  var _Selectors2 = require("../Reducers/ImageGallery/Selectors");
28
28
  var _Selectors3 = require("../Reducers/Configurator/Selectors");
29
+ var _getDesignAreaBorderColor = require("../Utils/getDesignAreaBorderColor");
29
30
  var _Selectors4 = require("../Reducers/Fonts/Selectors");
30
31
  var _Actions5 = require("../Reducers/Confirm/Actions");
31
32
  var _Actions6 = require("../Reducers/ConfigurationMode/Actions");
@@ -1905,6 +1906,7 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
1905
1906
  var options,
1906
1907
  state,
1907
1908
  showHighlight,
1909
+ staticBorderColor,
1908
1910
  textureBuilderConfig,
1909
1911
  variantIdentifier,
1910
1912
  effectEnabled,
@@ -1916,10 +1918,14 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
1916
1918
  options = _args22.length > 2 && _args22[2] !== undefined ? _args22[2] : {};
1917
1919
  state = _ServiceLocator.Services.store.state;
1918
1920
  showHighlight = (0, _get2["default"])(state, 'designArea.showHighlight');
1921
+ staticBorderColor = (0, _Selectors3.getClientHighlightColor)(state);
1919
1922
  textureBuilderConfig = (0, _merge["default"])({
1920
1923
  highlight: {
1921
- borderColor: (0, _Selectors3.getClientHighlightColor)(state),
1922
- backgroundColor: (0, _configuration.getConf)('visualization.designAreaHighlightBackgroundColor')
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,
1927
+ backgroundColor: (0, _configuration.getConf)('visualization.designAreaHighlightBackgroundColor'),
1928
+ lineWidth: (0, _configuration.getConf)('visualization.designAreaHighlightBorderWidth', 2)
1923
1929
  }
1924
1930
  }, options.textureBuilderConfig);
1925
1931
  variantIdentifier = (0, _get2["default"])(updateEventObject, 'data.variant', false);
@@ -2113,22 +2119,22 @@ var DesignDataService = exports["default"] = /*#__PURE__*/function () {
2113
2119
  return _ref17.apply(this, arguments);
2114
2120
  };
2115
2121
  }();
2116
- _context22.prev = 7;
2117
- _context22.next = 10;
2122
+ _context22.prev = 8;
2123
+ _context22.next = 11;
2118
2124
  return getDrawingContext(drawingFn, true /* update alpha */, true /* clean up texture before drawing */);
2119
- case 10:
2120
- _context22.next = 15;
2125
+ case 11:
2126
+ _context22.next = 16;
2121
2127
  break;
2122
- case 12:
2123
- _context22.prev = 12;
2124
- _context22.t0 = _context22["catch"](7);
2128
+ case 13:
2129
+ _context22.prev = 13;
2130
+ _context22.t0 = _context22["catch"](8);
2125
2131
  // eslint-disable-next-line no-console
2126
2132
  console.warn('Could not get drawing context, texture update is skipped!', _context22.t0.message);
2127
- case 15:
2133
+ case 16:
2128
2134
  case "end":
2129
2135
  return _context22.stop();
2130
2136
  }
2131
- }, _callee21, null, [[7, 12]]);
2137
+ }, _callee21, null, [[8, 13]]);
2132
2138
  }));
2133
2139
  function redrawAllDesignAreas(_x20, _x21) {
2134
2140
  return _redrawAllDesignAreas.apply(this, arguments);
@@ -185,6 +185,18 @@ var VisualizationService = exports["default"] = /*#__PURE__*/function (_Abstract
185
185
  value: function render() {
186
186
  return (0, _sendMessage["default"])(this.getComponent(), 'render');
187
187
  }
188
+
189
+ /**
190
+ * Average color of the product image underneath the border of the given design area.
191
+ * Only the 2D visualization implements this, everything else returns undefined.
192
+ * @param {string} designAreaIdentifier
193
+ * @return {{r: number, g: number, b: number}|null|undefined}
194
+ */
195
+ }, {
196
+ key: "getDesignAreaUnderlyingColor",
197
+ value: function getDesignAreaUnderlyingColor(designAreaIdentifier) {
198
+ return (0, _sendMessage["default"])(this.getComponent(), 'getDesignAreaUnderlyingColor', designAreaIdentifier);
199
+ }
188
200
  }, {
189
201
  key: "update2DMaxZoom",
190
202
  value: function update2DMaxZoom(maxZoom) {
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.rgbToHsl = exports.invertColor = exports.getContrastBorderColor = exports.getComplementaryBorderColor = exports.getBorderColorForAverage = exports.createBorderColorResolver = exports.BorderColorModes = void 0;
7
+ var _styles = require("@material-ui/core/styles");
8
+ var _Selectors = require("../Reducers/DesignArea/Selectors");
9
+ /**
10
+ * Minimum contrast between the border and the area it is drawn on, the WCAG AA requirement for
11
+ * graphical objects. Below it the derived color is not considered visible enough.
12
+ * @type {number}
13
+ */
14
+ var MIN_CONTRAST_RATIO = 3;
15
+ /**
16
+ * Below this saturation the inverted color carries no usable hue, e.g. any grey inverts into
17
+ * another grey. Those fall back to a neutral border.
18
+ * @type {number}
19
+ */
20
+ var MIN_SATURATION = 0.1;
21
+ /**
22
+ * The border keeps the complementary hue at a fixed high saturation, so it reads as an actual color
23
+ * instead of the pale tint that inverting a dark color produces.
24
+ * @type {number}
25
+ */
26
+ var BORDER_SATURATION = 85;
27
+ /**
28
+ * Candidate lightness values, ordered from the most vivid one outwards. The first one that contrasts
29
+ * enough with the average wins, so the border only gets as pale (on a dark product) or as deep (on a
30
+ * light one) as it has to be to stay visible.
31
+ */
32
+ var LIGHTNESS_ON_DARK_AVERAGE = [60, 65, 70, 75, 80, 85, 90, 95];
33
+ var LIGHTNESS_ON_LIGHT_AVERAGE = [40, 35, 30, 25, 20, 15, 10, 5];
34
+ /**
35
+ * Relative luminance above which the sampled average counts as light, roughly a mid grey.
36
+ * @type {number}
37
+ */
38
+ var LIGHT_AVERAGE_LUMINANCE = 0.2;
39
+ var NEUTRAL_DARK = '#111111';
40
+ var NEUTRAL_LIGHT = '#eeeeee';
41
+
42
+ /**
43
+ * @param {{r: number, g: number, b: number}} color
44
+ * @return {{r: number, g: number, b: number}}
45
+ */
46
+ var invertColor = exports.invertColor = function invertColor(_ref) {
47
+ var r = _ref.r,
48
+ g = _ref.g,
49
+ b = _ref.b;
50
+ return {
51
+ r: 255 - r,
52
+ g: 255 - g,
53
+ b: 255 - b
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Hue and saturation of an rgb color. Material-UI provides hslToRgb but not the other direction.
59
+ * @param {{r: number, g: number, b: number}} color
60
+ * @return {{hue: number, saturation: number, lightness: number}} Hue in degrees, rest in 0..1
61
+ */
62
+ var rgbToHsl = exports.rgbToHsl = function rgbToHsl(_ref2) {
63
+ var r = _ref2.r,
64
+ g = _ref2.g,
65
+ b = _ref2.b;
66
+ var red = r / 255;
67
+ var green = g / 255;
68
+ var blue = b / 255;
69
+ var max = Math.max(red, green, blue);
70
+ var min = Math.min(red, green, blue);
71
+ var delta = max - min;
72
+ var lightness = (max + min) / 2;
73
+ if (!delta) {
74
+ return {
75
+ hue: 0,
76
+ saturation: 0,
77
+ lightness: lightness
78
+ };
79
+ }
80
+ var hue;
81
+ if (max === red) {
82
+ hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
83
+ } else if (max === green) {
84
+ hue = ((blue - red) / delta + 2) * 60;
85
+ } else {
86
+ hue = ((red - green) / delta + 4) * 60;
87
+ }
88
+ return {
89
+ hue: hue,
90
+ saturation: delta / (1 - Math.abs(2 * lightness - 1)),
91
+ lightness: lightness
92
+ };
93
+ };
94
+
95
+ /**
96
+ * The available ways to derive the border color from the sampled product color.
97
+ * @see getBorderColorForAverage
98
+ */
99
+ var BorderColorModes = exports.BorderColorModes = {
100
+ Contrast: 'contrast',
101
+ Complementary: 'complementary'
102
+ };
103
+
104
+ /**
105
+ * A near black or a near white border, whichever reads better on the given color.
106
+ * The border is only about one pixel wide, and for such a thin line a strong difference in
107
+ * brightness is what makes it visible, much more than a difference in hue.
108
+ * @param {string} averageHex
109
+ * @return {string} A hex color
110
+ */
111
+ var getContrastBorderColor = exports.getContrastBorderColor = function getContrastBorderColor(averageHex) {
112
+ return (0, _styles.getContrastRatio)(averageHex, NEUTRAL_DARK) >= (0, _styles.getContrastRatio)(averageHex, NEUTRAL_LIGHT) ? NEUTRAL_DARK : NEUTRAL_LIGHT;
113
+ };
114
+
115
+ /**
116
+ * Keeps the hue of the inverted (complementary) color at a high saturation and moves the lightness
117
+ * away from the average, only as far as needed to reach a visible contrast, so the border is a vivid
118
+ * color rather than the pale tint that plain inversion produces.
119
+ * Averages without a usable hue, such as any grey, and the case where no candidate contrasts enough,
120
+ * fall back to a neutral border.
121
+ * @param {{r: number, g: number, b: number}} average
122
+ * @param {string} averageHex
123
+ * @return {string} A hex color
124
+ */
125
+ var getComplementaryBorderColor = exports.getComplementaryBorderColor = function getComplementaryBorderColor(average, averageHex) {
126
+ var _rgbToHsl = rgbToHsl(invertColor(average)),
127
+ hue = _rgbToHsl.hue,
128
+ saturation = _rgbToHsl.saturation;
129
+ if (saturation >= MIN_SATURATION) {
130
+ 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];
133
+ var candidate = (0, _Selectors.rgbString2hex)((0, _styles.hslToRgb)("hsl(".concat(Math.round(hue), ", ").concat(BORDER_SATURATION, "%, ").concat(lightness, "%)")));
134
+ if (candidate && (0, _styles.getContrastRatio)(averageHex, candidate) >= MIN_CONTRAST_RATIO) {
135
+ return candidate;
136
+ }
137
+ }
138
+ }
139
+ return getContrastBorderColor(averageHex);
140
+ };
141
+
142
+ /**
143
+ * Derives a border color from the average color of the area the border is drawn on.
144
+ * Anything but the complementary mode uses the contrast mode, so a missing or misspelled
145
+ * configuration value ends up with the most legible option.
146
+ * @param {{r: number, g: number, b: number}} average
147
+ * @param {string} [mode] One of BorderColorModes
148
+ * @return {string} A hex color
149
+ */
150
+ var getBorderColorForAverage = exports.getBorderColorForAverage = function getBorderColorForAverage(average) {
151
+ var mode = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : BorderColorModes.Contrast;
152
+ var averageHex = (0, _Selectors.rgbToHex)(average.r, average.g, average.b);
153
+ return mode === BorderColorModes.Complementary ? getComplementaryBorderColor(average, averageHex) : getContrastBorderColor(averageHex);
154
+ };
155
+
156
+ /**
157
+ * Creates the resolver that the texture builder calls for every highlighted design area.
158
+ * It falls back to the given color whenever the underlying color cannot be determined, e.g. for
159
+ * the 3D visualization, for fully transparent regions and for design areas that are not visible.
160
+ * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
161
+ * @param {string} fallbackColor
162
+ * @param {string} [mode] One of BorderColorModes
163
+ * @return {function(object): string}
164
+ */
165
+ var createBorderColorResolver = exports.createBorderColorResolver = function createBorderColorResolver(getUnderlyingColor, fallbackColor, mode) {
166
+ return function (designArea) {
167
+ var average = getUnderlyingColor(designArea && designArea.identifier);
168
+ return average ? getBorderColorForAverage(average, mode) : fallbackColor;
169
+ };
170
+ };
@@ -177,6 +177,19 @@ var applicationConfiguration = {
177
177
  },
178
178
 
179
179
  designAreaHighlightBackgroundColor: 'transparent',
180
+ // Width of the design area highlight border, in pixels at 100% zoom.
181
+ // Applies to the static border as well, 2 was the previously hard coded value.
182
+ designAreaHighlightBorderWidth: 2,
183
+ // Derive the design area highlight border color from the average color of the product
184
+ // image underneath the border, instead of using the client theme's highlightColor.
185
+ // This keeps the border visible on products whose color is close to the highlight color.
186
+ // 2D visualization only, the 3D one always uses the static color.
187
+ dynamicDesignAreaHighlightBorderColor: false,
188
+ // How the dynamic border color is derived from the sampled product color:
189
+ // 'contrast' near black or near white, whichever reads better (most legible, default)
190
+ // 'complementary' the inverted hue at a high saturation (more decorative, but a thin line
191
+ // in a mid bright color is much harder to see than a light or dark one)
192
+ dynamicDesignAreaHighlightBorderColorMode: 'contrast',
180
193
  options: {
181
194
  // when zoom = 1 use NearestFilter (for sharper product image)
182
195
  sharperFullView: false,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@configuratorware/configurator-frontendgui",
3
- "version": "1.57.6",
3
+ "version": "1.57.7",
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.6",
9
+ "@configuratorware/scripts": "1.57.7",
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.6",
40
- "redhotmagma-visualization": "1.57.6",
39
+ "redhotmagma-graphics-editor": "1.57.7",
40
+ "redhotmagma-visualization": "1.57.7",
41
41
  "redux": "^4.1.0",
42
42
  "redux-logger": "^3.0.6",
43
43
  "redux-persist": "^5.10.0",
@@ -107,7 +107,23 @@ const externalConfiguration = {
107
107
  //displayEditorRulers: true,
108
108
 
109
109
  /*visualization: {
110
+ useVisualizationScript: {
111
+ src: '', // source of the visualization script in the public folder, e.g. 'visualization.js'
112
+ },
110
113
  designAreaHighlightBackgroundColor: 'transparent',
114
+ // Width of the design area highlight border, in screen pixels. Stays the same at any zoom
115
+ // level and for any design area size. Applies to the static border as well.
116
+ designAreaHighlightBorderWidth: 2,
117
+ // Derive the design area highlight border color from the average color of the product
118
+ // image underneath the border, instead of using the client theme's highlightColor.
119
+ // This keeps the border visible on products whose color is close to the highlight color.
120
+ // 2D visualization only, the 3D one always uses the static color.
121
+ dynamicDesignAreaHighlightBorderColor: false,
122
+ // How the dynamic border color is derived from the sampled product color:
123
+ // 'contrast' near black or near white, whichever reads better (most legible, default)
124
+ // 'complementary' the inverted hue at a high saturation (more decorative, but a thin line
125
+ // in a mid bright color is much harder to see than a light or dark one)
126
+ dynamicDesignAreaHighlightBorderColorMode: 'contrast',
111
127
  options: {
112
128
  // when zoom = 1 use NearestFilter (for sharper product image)
113
129
  sharperFullView: false,
@@ -115,12 +131,29 @@ const externalConfiguration = {
115
131
  // Warning! not recommended, use better image resources instead
116
132
  optimizeProductImage: false,
117
133
  },
134
+ options3D: {
135
+ // create a ground to show a shadow under the model
136
+ useShadowGround: true,
137
+ // remove the shadow ground for screenshots
138
+ removeGroundForScreenshot: true,
139
+ // use an environment map
140
+ useEnvironmentMap: true,
141
+ // use programmatically generated baked shadow (instead of directional light shadow)
142
+ useBakedShadows: false,
143
+ // baked shadow parameters
144
+ bakedShadow: {
145
+ blurRadius: 6,
146
+ shadowOpacity: 0.8,
147
+ },
148
+ },
118
149
  },*/
119
150
 
120
151
  /*designer: {
121
152
  dpi: 300, // the default canvas dpi as project specific option
122
153
  maxColorAmount: 10, // the default max number of colors in the colorizing feature
123
154
  allowedImageUploadFormats: 'jpg,jpeg,svg,png,pdf,bmp,tif,tiff,eps,ai,gif', // the image formats users can upload to designareas / designer items
155
+ includeMetricContentSize: false, // set true to include the metric content size in the design data
156
+ bulkNamesPerNameDesignFiles: false, // set true to generate individual production svgs (-> production pdf in zip download) for each bulk name
124
157
  },*/
125
158
 
126
159
  //vectorizeImageQuality: 45000,
@@ -108,6 +108,7 @@ import {
108
108
  isDesignerProductPreviewMode,
109
109
  visualizationModeCheck,
110
110
  } from '../Reducers/Configurator/Selectors';
111
+ import { createBorderColorResolver } from '../Utils/getDesignAreaBorderColor';
111
112
  import { getFontMapping } from '../Reducers/Fonts/Selectors';
112
113
  import { isConfirmResult } from '../Reducers/Confirm/Actions';
113
114
  import { selectConfigurationMode } from '../Reducers/ConfigurationMode/Actions';
@@ -1602,11 +1603,19 @@ export default class DesignDataService {
1602
1603
 
1603
1604
  const showHighlight = get(state, 'designArea.showHighlight');
1604
1605
 
1606
+ const staticBorderColor = getClientHighlightColor(state);
1605
1607
  const textureBuilderConfig = merge(
1606
1608
  {
1607
1609
  highlight: {
1608
- borderColor: getClientHighlightColor(state),
1610
+ borderColor: getConf('visualization.dynamicDesignAreaHighlightBorderColor')
1611
+ ? createBorderColorResolver(
1612
+ identifier => Services.visualization.getDesignAreaUnderlyingColor(identifier),
1613
+ staticBorderColor,
1614
+ getConf('visualization.dynamicDesignAreaHighlightBorderColorMode')
1615
+ )
1616
+ : staticBorderColor,
1609
1617
  backgroundColor: getConf('visualization.designAreaHighlightBackgroundColor'),
1618
+ lineWidth: getConf('visualization.designAreaHighlightBorderWidth', 2),
1610
1619
  },
1611
1620
  },
1612
1621
  options.textureBuilderConfig
@@ -147,6 +147,16 @@ export default class VisualizationService extends AbstractVisualizationService {
147
147
  return sendMessage(this.getComponent(), 'render');
148
148
  }
149
149
 
150
+ /**
151
+ * Average color of the product image underneath the border of the given design area.
152
+ * Only the 2D visualization implements this, everything else returns undefined.
153
+ * @param {string} designAreaIdentifier
154
+ * @return {{r: number, g: number, b: number}|null|undefined}
155
+ */
156
+ getDesignAreaUnderlyingColor(designAreaIdentifier) {
157
+ return sendMessage(this.getComponent(), 'getDesignAreaUnderlyingColor', designAreaIdentifier);
158
+ }
159
+
150
160
  update2DMaxZoom(maxZoom) {
151
161
  return sendMessage(this.getComponent(), 'updateMaxZoomSetting', maxZoom);
152
162
  }
@@ -0,0 +1,128 @@
1
+ import {
2
+ BorderColorModes,
3
+ createBorderColorResolver,
4
+ getBorderColorForAverage,
5
+ invertColor,
6
+ rgbToHsl,
7
+ } from '../getDesignAreaBorderColor';
8
+
9
+ // the dark green of the softshell demo item, the case the feature was built for
10
+ const JACKET_GREEN = { r: 45, g: 95, b: 25 };
11
+
12
+ test('invertColor inverts every channel', () => {
13
+ expect(invertColor({ r: 0, g: 128, b: 255 })).toEqual({ r: 255, g: 127, b: 0 });
14
+ });
15
+
16
+ describe('rgbToHsl', () => {
17
+ test('reports no saturation for greys', () => {
18
+ expect(rgbToHsl({ r: 128, g: 128, b: 128 })).toEqual({
19
+ hue: 0,
20
+ saturation: 0,
21
+ lightness: 128 / 255,
22
+ });
23
+ });
24
+
25
+ test('reports the hue of the primary colors', () => {
26
+ expect(Math.round(rgbToHsl({ r: 255, g: 0, b: 0 }).hue)).toBe(0);
27
+ expect(Math.round(rgbToHsl({ r: 0, g: 255, b: 0 }).hue)).toBe(120);
28
+ expect(Math.round(rgbToHsl({ r: 0, g: 0, b: 255 }).hue)).toBe(240);
29
+ expect(rgbToHsl({ r: 255, g: 0, b: 0 }).saturation).toBe(1);
30
+ });
31
+
32
+ test('reports the hue of a mixed color', () => {
33
+ // the complement of the dark green product color, a violet
34
+ expect(Math.round(rgbToHsl({ r: 210, g: 160, b: 230 }).hue)).toBe(283);
35
+ });
36
+ });
37
+
38
+ describe('getBorderColorForAverage in contrast mode', () => {
39
+ test('is the default mode', () => {
40
+ expect(getBorderColorForAverage(JACKET_GREEN)).toBe(
41
+ getBorderColorForAverage(JACKET_GREEN, BorderColorModes.Contrast)
42
+ );
43
+ });
44
+
45
+ test('falls back to the contrast mode for an unknown mode', () => {
46
+ expect(getBorderColorForAverage(JACKET_GREEN, 'something-else')).toBe(
47
+ getBorderColorForAverage(JACKET_GREEN, BorderColorModes.Contrast)
48
+ );
49
+ });
50
+
51
+ test('uses a near white border on a dark average', () => {
52
+ expect(getBorderColorForAverage(JACKET_GREEN, BorderColorModes.Contrast)).toBe('#eeeeee');
53
+ expect(getBorderColorForAverage({ r: 0, g: 0, b: 0 }, BorderColorModes.Contrast)).toBe('#eeeeee');
54
+ });
55
+
56
+ test('uses a near black border on a light average', () => {
57
+ expect(getBorderColorForAverage({ r: 255, g: 255, b: 255 }, BorderColorModes.Contrast)).toBe(
58
+ '#111111'
59
+ );
60
+ expect(getBorderColorForAverage({ r: 255, g: 255, b: 150 }, BorderColorModes.Contrast)).toBe(
61
+ '#111111'
62
+ );
63
+ });
64
+
65
+ test('picks the better readable neutral for greys', () => {
66
+ expect(getBorderColorForAverage({ r: 128, g: 128, b: 128 }, BorderColorModes.Contrast)).toBe(
67
+ '#111111'
68
+ );
69
+ expect(getBorderColorForAverage({ r: 100, g: 100, b: 100 }, BorderColorModes.Contrast)).toBe(
70
+ '#eeeeee'
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('getBorderColorForAverage in complementary mode', () => {
76
+ const complementary = average => getBorderColorForAverage(average, BorderColorModes.Complementary);
77
+
78
+ test('keeps the complementary hue at a high saturation', () => {
79
+ expect(complementary(JACKET_GREEN)).toBe('#d789f5');
80
+ });
81
+
82
+ test('goes darker on a light average', () => {
83
+ expect(complementary({ r: 255, g: 255, b: 150 })).toBe('#0f0fbd');
84
+ });
85
+
86
+ test('goes lighter on a dark average', () => {
87
+ expect(complementary({ r: 20, g: 30, b: 90 })).toBe('#f0d642');
88
+ });
89
+
90
+ test('falls back to a neutral border when there is no hue to keep', () => {
91
+ // grey inverts into another grey
92
+ expect(complementary({ r: 128, g: 128, b: 128 })).toBe('#111111');
93
+ expect(complementary({ r: 100, g: 100, b: 100 })).toBe('#eeeeee');
94
+ expect(complementary({ r: 0, g: 0, b: 0 })).toBe('#eeeeee');
95
+ expect(complementary({ r: 255, g: 255, b: 255 })).toBe('#111111');
96
+ });
97
+ });
98
+
99
+ describe('createBorderColorResolver', () => {
100
+ test('resolves the border color from the underlying color of the design area', () => {
101
+ const getUnderlyingColor = jest.fn().mockReturnValue(JACKET_GREEN);
102
+ const resolve = createBorderColorResolver(getUnderlyingColor, '#abcdef');
103
+
104
+ expect(resolve({ identifier: 'front' })).toBe('#eeeeee');
105
+ expect(getUnderlyingColor).toHaveBeenCalledWith('front');
106
+ });
107
+
108
+ test('passes the mode through', () => {
109
+ const resolve = createBorderColorResolver(
110
+ () => JACKET_GREEN,
111
+ '#abcdef',
112
+ BorderColorModes.Complementary
113
+ );
114
+
115
+ expect(resolve({ identifier: 'front' })).toBe('#d789f5');
116
+ });
117
+
118
+ test('falls back to the given color when there is no underlying color', () => {
119
+ expect(createBorderColorResolver(() => null, '#abcdef')({ identifier: 'front' })).toBe('#abcdef');
120
+ expect(createBorderColorResolver(() => undefined, '#abcdef')({ identifier: 'front' })).toBe(
121
+ '#abcdef'
122
+ );
123
+ });
124
+
125
+ test('falls back to the given color without a design area', () => {
126
+ expect(createBorderColorResolver(() => null, '#abcdef')()).toBe('#abcdef');
127
+ });
128
+ });
@@ -0,0 +1,158 @@
1
+ import { getContrastRatio, getLuminance, hslToRgb } from '@material-ui/core/styles';
2
+ import { rgbString2hex, rgbToHex } from '../Reducers/DesignArea/Selectors';
3
+
4
+ /**
5
+ * Minimum contrast between the border and the area it is drawn on, the WCAG AA requirement for
6
+ * graphical objects. Below it the derived color is not considered visible enough.
7
+ * @type {number}
8
+ */
9
+ const MIN_CONTRAST_RATIO = 3;
10
+ /**
11
+ * Below this saturation the inverted color carries no usable hue, e.g. any grey inverts into
12
+ * another grey. Those fall back to a neutral border.
13
+ * @type {number}
14
+ */
15
+ const MIN_SATURATION = 0.1;
16
+ /**
17
+ * The border keeps the complementary hue at a fixed high saturation, so it reads as an actual color
18
+ * instead of the pale tint that inverting a dark color produces.
19
+ * @type {number}
20
+ */
21
+ const BORDER_SATURATION = 85;
22
+ /**
23
+ * Candidate lightness values, ordered from the most vivid one outwards. The first one that contrasts
24
+ * enough with the average wins, so the border only gets as pale (on a dark product) or as deep (on a
25
+ * light one) as it has to be to stay visible.
26
+ */
27
+ const LIGHTNESS_ON_DARK_AVERAGE = [60, 65, 70, 75, 80, 85, 90, 95];
28
+ const LIGHTNESS_ON_LIGHT_AVERAGE = [40, 35, 30, 25, 20, 15, 10, 5];
29
+ /**
30
+ * Relative luminance above which the sampled average counts as light, roughly a mid grey.
31
+ * @type {number}
32
+ */
33
+ const LIGHT_AVERAGE_LUMINANCE = 0.2;
34
+ const NEUTRAL_DARK = '#111111';
35
+ const NEUTRAL_LIGHT = '#eeeeee';
36
+
37
+ /**
38
+ * @param {{r: number, g: number, b: number}} color
39
+ * @return {{r: number, g: number, b: number}}
40
+ */
41
+ export const invertColor = ({ r, g, b }) => ({ r: 255 - r, g: 255 - g, b: 255 - b });
42
+
43
+ /**
44
+ * Hue and saturation of an rgb color. Material-UI provides hslToRgb but not the other direction.
45
+ * @param {{r: number, g: number, b: number}} color
46
+ * @return {{hue: number, saturation: number, lightness: number}} Hue in degrees, rest in 0..1
47
+ */
48
+ export const rgbToHsl = ({ r, g, b }) => {
49
+ const red = r / 255;
50
+ const green = g / 255;
51
+ const blue = b / 255;
52
+ const max = Math.max(red, green, blue);
53
+ const min = Math.min(red, green, blue);
54
+ const delta = max - min;
55
+ const lightness = (max + min) / 2;
56
+
57
+ if (!delta) {
58
+ return { hue: 0, saturation: 0, lightness };
59
+ }
60
+
61
+ let hue;
62
+ if (max === red) {
63
+ hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
64
+ } else if (max === green) {
65
+ hue = ((blue - red) / delta + 2) * 60;
66
+ } else {
67
+ hue = ((red - green) / delta + 4) * 60;
68
+ }
69
+
70
+ return {
71
+ hue,
72
+ saturation: delta / (1 - Math.abs(2 * lightness - 1)),
73
+ lightness,
74
+ };
75
+ };
76
+
77
+ /**
78
+ * The available ways to derive the border color from the sampled product color.
79
+ * @see getBorderColorForAverage
80
+ */
81
+ export const BorderColorModes = {
82
+ Contrast: 'contrast',
83
+ Complementary: 'complementary',
84
+ };
85
+
86
+ /**
87
+ * A near black or a near white border, whichever reads better on the given color.
88
+ * The border is only about one pixel wide, and for such a thin line a strong difference in
89
+ * brightness is what makes it visible, much more than a difference in hue.
90
+ * @param {string} averageHex
91
+ * @return {string} A hex color
92
+ */
93
+ export const getContrastBorderColor = averageHex =>
94
+ getContrastRatio(averageHex, NEUTRAL_DARK) >= getContrastRatio(averageHex, NEUTRAL_LIGHT)
95
+ ? NEUTRAL_DARK
96
+ : NEUTRAL_LIGHT;
97
+
98
+ /**
99
+ * Keeps the hue of the inverted (complementary) color at a high saturation and moves the lightness
100
+ * away from the average, only as far as needed to reach a visible contrast, so the border is a vivid
101
+ * color rather than the pale tint that plain inversion produces.
102
+ * Averages without a usable hue, such as any grey, and the case where no candidate contrasts enough,
103
+ * fall back to a neutral border.
104
+ * @param {{r: number, g: number, b: number}} average
105
+ * @param {string} averageHex
106
+ * @return {string} A hex color
107
+ */
108
+ export const getComplementaryBorderColor = (average, averageHex) => {
109
+ const { hue, saturation } = rgbToHsl(invertColor(average));
110
+
111
+ if (saturation >= MIN_SATURATION) {
112
+ const lightnessCandidates =
113
+ getLuminance(averageHex) > LIGHT_AVERAGE_LUMINANCE
114
+ ? LIGHTNESS_ON_LIGHT_AVERAGE
115
+ : LIGHTNESS_ON_DARK_AVERAGE;
116
+
117
+ for (const lightness of lightnessCandidates) {
118
+ const candidate = rgbString2hex(
119
+ hslToRgb(`hsl(${Math.round(hue)}, ${BORDER_SATURATION}%, ${lightness}%)`)
120
+ );
121
+ if (candidate && getContrastRatio(averageHex, candidate) >= MIN_CONTRAST_RATIO) {
122
+ return candidate;
123
+ }
124
+ }
125
+ }
126
+
127
+ return getContrastBorderColor(averageHex);
128
+ };
129
+
130
+ /**
131
+ * Derives a border color from the average color of the area the border is drawn on.
132
+ * Anything but the complementary mode uses the contrast mode, so a missing or misspelled
133
+ * configuration value ends up with the most legible option.
134
+ * @param {{r: number, g: number, b: number}} average
135
+ * @param {string} [mode] One of BorderColorModes
136
+ * @return {string} A hex color
137
+ */
138
+ export const getBorderColorForAverage = (average, mode = BorderColorModes.Contrast) => {
139
+ const averageHex = rgbToHex(average.r, average.g, average.b);
140
+
141
+ return mode === BorderColorModes.Complementary
142
+ ? getComplementaryBorderColor(average, averageHex)
143
+ : getContrastBorderColor(averageHex);
144
+ };
145
+
146
+ /**
147
+ * Creates the resolver that the texture builder calls for every highlighted design area.
148
+ * It falls back to the given color whenever the underlying color cannot be determined, e.g. for
149
+ * the 3D visualization, for fully transparent regions and for design areas that are not visible.
150
+ * @param {function(string): ({r: number, g: number, b: number}|null|undefined)} getUnderlyingColor
151
+ * @param {string} fallbackColor
152
+ * @param {string} [mode] One of BorderColorModes
153
+ * @return {function(object): string}
154
+ */
155
+ export const createBorderColorResolver = (getUnderlyingColor, fallbackColor, mode) => designArea => {
156
+ const average = getUnderlyingColor(designArea && designArea.identifier);
157
+ return average ? getBorderColorForAverage(average, mode) : fallbackColor;
158
+ };
@@ -186,6 +186,19 @@ let applicationConfiguration = {
186
186
  src: '', // source of the visualization script in the public folder, e.g. 'visualization.js'
187
187
  },
188
188
  designAreaHighlightBackgroundColor: 'transparent',
189
+ // Width of the design area highlight border, in pixels at 100% zoom.
190
+ // Applies to the static border as well, 2 was the previously hard coded value.
191
+ designAreaHighlightBorderWidth: 2,
192
+ // Derive the design area highlight border color from the average color of the product
193
+ // image underneath the border, instead of using the client theme's highlightColor.
194
+ // This keeps the border visible on products whose color is close to the highlight color.
195
+ // 2D visualization only, the 3D one always uses the static color.
196
+ dynamicDesignAreaHighlightBorderColor: false,
197
+ // How the dynamic border color is derived from the sampled product color:
198
+ // 'contrast' near black or near white, whichever reads better (most legible, default)
199
+ // 'complementary' the inverted hue at a high saturation (more decorative, but a thin line
200
+ // in a mid bright color is much harder to see than a light or dark one)
201
+ dynamicDesignAreaHighlightBorderColorMode: 'contrast',
189
202
  options: {
190
203
  // when zoom = 1 use NearestFilter (for sharper product image)
191
204
  sharperFullView: false,