1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
|
import { isSome } from '@jet/environment/types/optional';
import type {
Artwork,
Color,
RGBColor,
NamedColor,
} from '@jet-app/app-store/api/models';
export type RGB = [number, number, number];
/**
* Represents a valid RGB color string, in the format "rgb(r, g, b)" or "rgb(r,g,b)".
* @example
* "rgb(255, 0, 128)"
* "rgb(255,0,128)"
*/
type RGBString =
| `rgb(${number},${number},${number})`
| `rgb(${number}, ${number}, ${number})`;
export const isRGBColor = (value: Color): value is RGBColor =>
value.type === 'rgb';
export const isNamedColor = (value: Color): value is NamedColor =>
value.type === 'named';
const rgbColorAsString = ({ red, green, blue }: RGBColor): string =>
`rgb(${[red, green, blue].map((color) => Math.floor(255 * color)).join()})`;
export const colorAsString = (color: Color): string => {
switch (color.type) {
case 'named':
// `ios-appstore-app` makes use of the this `placeholderBackground` named color,
// which it leaves up to the client to manage. Ideally, we could define a CSS property
// named `--placeholderBackground`, but the media-apps shared logic to determine Artwork
// background color doesn't respect CSS properties, so we are specifying the hex value.
// https://github.pie.apple.com/amp-web/media-apps/blame/main/shared/components/src/components/Artwork/utils/validateBackground.ts
if (color.name === 'placeholderBackground') {
return '#f1f1f1';
}
return `var(--${color.name})`;
case 'rgb':
return rgbColorAsString(color);
case 'dynamic':
return colorAsString(color.lightColor);
}
};
/**
* Parses an RGB string and returns an array of red, green, and blue values.
*
* This function extracts the numeric values from an RGB string (e.g., "rgb(255, 0, 128)")
* and returns them as an array of numbers.
*
* @param {RGBString} rgbString - The RGB string to parse.
* @returns {RGB} An array of three numbers representing the red, green, and blue values, each between 0 and 255.
*
* @example
* getRGBFromString("rgb(255, 0, 128)") = [255, 0, 128]
*/
export const getRGBFromString = (rgbString: RGBString): RGB => {
const rgbValues = rgbString.match(/\d+/g) ?? [];
const rgb: RGB = [0, 0, 0];
for (const [index] of rgb.entries()) {
rgb[index] = parseInt(rgbValues[index]);
}
return rgb;
};
/**
* Calculates the relative luminance for an RGB color.
*
* This function uses a standardized formula for luminance, which weights the red, green, and blue
* channels differently to account for human perception.
* @see {@link https://en.wikipedia.org/wiki/Relative_luminance|Wikipedia: Relative Luminance}
*
* @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
* @returns {number} The calculated luminance value, a number between 0 (darkest) and 255 (lightest).
*/
export const getLuminanceForRGB = ([r, g, b]: RGB): number => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export function isRGBDarkerThanThreshold([r, g, b]: RGB, threshold = 10) {
return r <= threshold && g <= threshold && b <= threshold;
}
export function isDark(rgbColor: RGBColor): boolean {
const { red, green, blue } = rgbColor;
const rgbValues = [red, green, blue].map((channel) =>
Math.floor(channel * 255),
) as RGB;
return isRGBDarkerThanThreshold(rgbValues, 127);
}
/**
* Determines whether an RGB color is approximately grey based on channel similarity.
*
* @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
* @param {number} [threshold=10] - Maximum allowed difference between color channels to still be considered grey-ish.
* @returns {boolean} True if the RGB values are close enough to be considered grey.
*/
function isKindOfGrey([r, g, b]: RGB, threshold = 10) {
return (
Math.abs(r - g) <= threshold &&
Math.abs(r - b) <= threshold &&
Math.abs(g - b) <= threshold
);
}
/**
* Generates CSS variables (custom properties) for a background gradient based on the background
* colors in the specified list of artworks.
*
* @param {Artwork[]} artworks - An array of Artwork, each containing a `backgroundColor` property.
* @param {Object} [options={}] - Optional configuration options.
* @param {string[]} [options.variableNames=['bottom-left', 'top-right', 'bottom-right', 'top-left']] -
* The names of the CSS variables to assign to the extracted colors. The number of colors
* used will match the length of this array.
* @param {(a: RGB, b: RGB) => number} [options.sortFn=() => 0] -
* A sorting function for ordering the colors (e.g., by luminance). Defaults to no sorting,
* which preserves input order.
*
* @returns {string} A CSS string containing custom properties, e.g.,
* "--bottom-left: rgb(255, 0, 0); --top-right: rgb(0, 255, 0);".
*/
export const getBackgroundGradientCSSVarsFromArtworks = (
artworks: Artwork[],
{
variableNames = [
'bottom-left',
'top-right',
'bottom-right',
'top-left',
],
sortFn = () => 0,
shouldRemoveGreys = false,
}: {
variableNames?: string[];
sortFn?: (a: RGB, b: RGB) => number;
shouldRemoveGreys?: boolean;
} = {},
): string => {
return artworks
.map(({ backgroundColor }) => backgroundColor)
.filter(isSome)
.filter(isRGBColor)
.map(
({ red, green, blue }): RGB => [
Math.floor(255 * red),
Math.floor(255 * green),
Math.floor(255 * blue),
],
)
.filter((rgb) => !isRGBDarkerThanThreshold(rgb, 33))
.filter((rgb) => (shouldRemoveGreys ? !isKindOfGrey(rgb, 10) : true))
.sort(sortFn)
.slice(0, variableNames.length)
.map(
([red, green, blue], index) =>
`--${variableNames[index]}: rgb(${red}, ${green}, ${blue})`,
)
.join('; ');
};
|