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