/ src / utils / color.ts
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  };