diff --git a/packages/react-components/theme-designer/package.json b/packages/react-components/theme-designer/package.json index eb1f0890999e06..fbe096f30eba05 100644 --- a/packages/react-components/theme-designer/package.json +++ b/packages/react-components/theme-designer/package.json @@ -37,7 +37,6 @@ "tslib": "^2.1.0", "@fluentui/react-components": "^9.18.0", "@fluentui/react-icons": "^2.0.175", - "@fluent-blocks/colors": "9.2.0", "codesandbox-import-utils": "2.2.3", "@types/dedent": "0.7.0", "@fluentui/react-alert": "9.0.0-beta.38", diff --git a/packages/react-components/theme-designer/src/colors/csswg.ts b/packages/react-components/theme-designer/src/colors/csswg.ts new file mode 100644 index 00000000000000..a3febbcb0c3575 --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/csswg.ts @@ -0,0 +1,774 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// The following is a combination of several files retrieved from CSSWG’s +// CSS Color 4 module. It was modified to support TypeScript types adapted for +// the Fluent Blocks `colors` package and formatted to meet its style criteria. +import { Vec2, Vec3, Vec4 } from './types'; + +// [willshown]: Adjusted to export a TypeScript module. Retrieved on 24 May 2021 +// from https://drafts.csswg.org/css-color-4/multiply-matrices.js + +/** + * Simple matrix (and vector) multiplication + * Warning: No error handling for incompatible dimensions! + * @author Lea Verou 2020 MIT License + */ + +type MatrixIO = number[][] | number[]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isFlat(A: any): A is number[] { + return !Array.isArray(A[0]); +} + +// A is m x n. B is n x p. product is m x p. +export default function multiplyMatrices(AMatrixOrVector: MatrixIO, BMatrixOrVector: MatrixIO): MatrixIO { + const m = AMatrixOrVector.length; + + const A: number[][] = isFlat(AMatrixOrVector) + ? // A is vector, convert to [[a, b, c, ...]] + [AMatrixOrVector] + : AMatrixOrVector; + + const B: number[][] = isFlat(BMatrixOrVector) + ? // B is vector, convert to [[a], [b], [c], ...]] + BMatrixOrVector.map(x => [x]) + : BMatrixOrVector; + + const p = B[0].length; + const B_cols = B[0].map((_, i) => B.map(x => x[i])); // transpose B + let product: MatrixIO = A.map(row => + B_cols.map(col => { + if (!Array.isArray(row)) { + return col.reduce((a, c) => a + c * row, 0); + } + + return row.reduce((a, c, i) => a + c * (col[i] || 0), 0); + }), + ); + + if (m === 1) { + product = product[0]; // Avoid [[a, b, c, ...]] + } + + if (p === 1) { + return (product as number[][]).map(x => x[0]); // Avoid [[a], [b], [c], ...]] + } + + return product; +} + +// Sample code for color conversions +// Conversion can also be done using ICC profiles and a Color Management System +// For clarity, a library is used for matrix multiplication (multiply-matrices.js) + +// [willshown]: Adjusted to export a TypeScript module. Retrieved on 24 May 2021 +// from https://drafts.csswg.org/css-color-4/conversions.js + +// sRGB-related functions + +export function lin_sRGB(RGB: Vec3) { + // convert an array of sRGB values + // where in-gamut values are in the range [0 - 1] + // to linear light (un-companded) form. + // https://en.wikipedia.org/wiki/SRGB + // Extended transfer function: + // for negative values, linear portion is extended on reflection of axis, + // then reflected power function is used. + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs < 0.04045) { + return val / 12.92; + } + + return sign * Math.pow((abs + 0.055) / 1.055, 2.4); + }) as Vec3; +} + +export function gam_sRGB(RGB: Vec3) { + // convert an array of linear-light sRGB values in the range 0.0-1.0 + // to gamma corrected form + // https://en.wikipedia.org/wiki/SRGB + // Extended transfer function: + // For negative values, linear portion extends on reflection + // of axis, then uses reflected pow below that + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs > 0.0031308) { + return sign * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055); + } + + return 12.92 * val; + }) as Vec3; +} + +export function lin_sRGB_to_XYZ(rgb: Vec3) { + // convert an array of linear-light sRGB values to CIE XYZ + // using sRGB's own white, D65 (no chromatic adaptation) + + const M = [ + [0.41239079926595934, 0.357584339383878, 0.1804807884018343], + [0.21263900587151027, 0.715168678767756, 0.07219231536073371], + [0.01933081871559182, 0.11919477979462598, 0.9505321522496607], + ]; + return multiplyMatrices(M, rgb) as Vec3; +} + +export function XYZ_to_lin_sRGB(XYZ: Vec3) { + // convert XYZ to linear-light sRGB + + const M = [ + [3.2409699419045226, -1.537383177570094, -0.4986107602930034], + [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559], + [0.05563007969699366, -0.20397695888897652, 1.0569715142428786], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// display-p3-related functions + +export function lin_P3(RGB: Vec3) { + // convert an array of display-p3 RGB values in the range 0.0 - 1.0 + // to linear light (un-companded) form. + + return lin_sRGB(RGB) as Vec3; // same as sRGB +} + +export function gam_P3(RGB: Vec3) { + // convert an array of linear-light display-p3 RGB in the range 0.0-1.0 + // to gamma corrected form + + return gam_sRGB(RGB) as Vec3; // same as sRGB +} + +export function lin_P3_to_XYZ(rgb: Vec3) { + // convert an array of linear-light display-p3 values to CIE XYZ + // using D65 (no chromatic adaptation) + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + const M = [ + [0.4865709486482162, 0.26566769316909306, 0.1982172852343625], + [0.2289745640697488, 0.6917385218365064, 0.079286914093745], + [0.0, 0.04511338185890264, 1.043944368900976], + ]; + // 0 was computed as -3.972075516933488e-17 + + return multiplyMatrices(M, rgb) as Vec3; +} + +export function XYZ_to_lin_P3(XYZ: Vec3) { + // convert XYZ to linear-light P3 + const M = [ + [2.493496911941425, -0.9313836179191239, -0.40271078445071684], + [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577], + [0.03584583024378447, -0.07617238926804182, 0.9568845240076872], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// prophoto-rgb functions + +export function lin_ProPhoto(RGB: Vec3) { + // convert an array of prophoto-rgb values + // where in-gamut colors are in the range [0.0 - 1.0] + // to linear light (un-companded) form. + // Transfer curve is gamma 1.8 with a small linear portion + // Extended transfer function + const Et2 = 16 / 512; + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs <= Et2) { + return val / 16; + } + + return sign * Math.pow(val, 1.8); + }) as Vec3; +} + +export function gam_ProPhoto(RGB: Vec3) { + // convert an array of linear-light prophoto-rgb in the range 0.0-1.0 + // to gamma corrected form + // Transfer curve is gamma 1.8 with a small linear portion + // TODO for negative values, extend linear portion on reflection of axis, then add pow below that + const Et = 1 / 512; + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs >= Et) { + return sign * Math.pow(abs, 1 / 1.8); + } + + return 16 * val; + }) as Vec3; +} + +export function lin_ProPhoto_to_XYZ(rgb: Vec3) { + // convert an array of linear-light prophoto-rgb values to CIE XYZ + // using D50 (so no chromatic adaptation needed afterwards) + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + const M = [ + [0.7977604896723027, 0.13518583717574031, 0.0313493495815248], + [0.2880711282292934, 0.7118432178101014, 0.00008565396060525902], + [0.0, 0.0, 0.8251046025104601], + ]; + + return multiplyMatrices(M, rgb) as Vec3; +} + +export function XYZ_to_lin_ProPhoto(XYZ: Vec3) { + // convert XYZ to linear-light prophoto-rgb + const M = [ + [1.3457989731028281, -0.25558010007997534, -0.05110628506753401], + [-0.5446224939028347, 1.5082327413132781, 0.02053603239147973], + [0.0, 0.0, 1.2119675456389454], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// a98-rgb functions + +export function lin_a98rgb(RGB: Vec3) { + // convert an array of a98-rgb values in the range 0.0 - 1.0 + // to linear light (un-companded) form. + // negative values are also now accepted + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + return sign * Math.pow(abs, 563 / 256); + }) as Vec3; +} + +export function gam_a98rgb(RGB: Vec3) { + // convert an array of linear-light a98-rgb in the range 0.0-1.0 + // to gamma corrected form + // negative values are also now accepted + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + return sign * Math.pow(abs, 256 / 563); + }) as Vec3; +} + +export function lin_a98rgb_to_XYZ(rgb: Vec3) { + // convert an array of linear-light a98-rgb values to CIE XYZ + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + // has greater numerical precision than section 4.3.5.3 of + // https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf + // but the values below were calculated from first principles + // from the chromaticity coordinates of R G B W + // see matrixmaker.html + const M = [ + [0.5766690429101305, 0.1855582379065463, 0.1882286462349947], + [0.29734497525053605, 0.6273635662554661, 0.07529145849399788], + [0.02703136138641234, 0.07068885253582723, 0.9913375368376388], + ]; + + return multiplyMatrices(M, rgb) as Vec3; +} + +export function XYZ_to_lin_a98rgb(XYZ: Vec3) { + // convert XYZ to linear-light a98-rgb + const M = [ + [2.0415879038107465, -0.5650069742788596, -0.34473135077832956], + [-0.9692436362808795, 1.8759675015077202, 0.04155505740717557], + [0.013444280632031142, -0.11836239223101838, 1.0151749943912054], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// Rec. 2020-related functions + +export function lin_2020(RGB: Vec3) { + // convert an array of rec2020 RGB values in the range 0.0 - 1.0 + // to linear light (un-companded) form. + // ITU-R BT.2020-2 p.4 + + const α = 1.09929682680944; + const β = 0.018053968510807; + + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs < β * 4.5) { + return val / 4.5; + } + + return sign * Math.pow((abs + α - 1) / α, 1 / 0.45); + }) as Vec3; +} + +export function gam_2020(RGB: Vec3) { + // convert an array of linear-light rec2020 RGB in the range 0.0-1.0 + // to gamma corrected form + // ITU-R BT.2020-2 p.4 + + const α = 1.09929682680944; + const β = 0.018053968510807; + + return RGB.map(val => { + const sign = val < 0 ? -1 : 1; + const abs = Math.abs(val); + + if (abs > β) { + return sign * (α * Math.pow(abs, 0.45) - (α - 1)); + } + + return 4.5 * val; + }) as Vec3; +} + +export function lin_2020_to_XYZ(rgb: Vec3) { + // convert an array of linear-light rec2020 values to CIE XYZ + // using D65 (no chromatic adaptation) + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + const M = [ + [0.6369580483012914, 0.14461690358620832, 0.1688809751641721], + [0.2627002120112671, 0.6779980715188708, 0.05930171646986196], + [0.0, 0.028072693049087428, 1.060985057710791], + ]; + // 0 is actually calculated as 4.994106574466076e-17 + + return multiplyMatrices(M, rgb) as Vec3; +} + +export function XYZ_to_lin_2020(XYZ: Vec3) { + // convert XYZ to linear-light rec2020 + const M = [ + [1.7166511879712674, -0.35567078377639233, -0.25336628137365974], + [-0.6666843518324892, 1.6164812366349395, 0.01576854581391113], + [0.017639857445310783, -0.042770613257808524, 0.9421031212354738], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// Chromatic adaptation + +export function D65_to_D50(XYZ: Vec3) { + // Bradford chromatic adaptation from D65 to D50 + // The matrix below is the result of three operations: + // - convert from XYZ to retinal cone domain + // - scale components from one reference white to another + // - convert back to XYZ + // http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html + const M = [ + [1.0479298208405488, 0.022946793341019088, -0.05019222954313557], + [0.029627815688159344, 0.990434484573249, -0.01707382502938514], + [-0.009243058152591178, 0.015055144896577895, 0.7518742899580008], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +export function D50_to_D65(XYZ: Vec3) { + // Bradford chromatic adaptation from D50 to D65 + const M = [ + [0.9554734527042182, -0.023098536874261423, 0.0632593086610217], + [-0.028369706963208136, 1.0099954580058226, 0.021041398966943008], + [0.012314001688319899, -0.020507696433477912, 1.3303659366080753], + ]; + + return multiplyMatrices(M, XYZ) as Vec3; +} + +// Lab and LCH + +export function XYZ_to_Lab(XYZ: Vec3) { + // Assuming XYZ is relative to D50, convert to CIE Lab + // from CIE standard, which now defines these as a rational fraction + const ε = 216 / 24389; // 6^3/29^3 + const κ = 24389 / 27; // 29^3/3^3 + const white = [0.96422, 1.0, 0.82521]; // D50 reference white + + // compute xyz, which is XYZ scaled relative to reference white + const xyz = XYZ.map((value, i) => value / white[i]); + + // now compute f + const f = xyz.map(value => (value > ε ? Math.cbrt(value) : (κ * value + 16) / 116)); + + return [ + 116 * f[1] - 16, // L + 500 * (f[0] - f[1]), // a + 200 * (f[1] - f[2]), // b + ] as Vec3; +} + +export function Lab_to_XYZ(Lab: Vec3) { + // Convert Lab to D50-adapted XYZ + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + const κ = 24389 / 27; // 29^3/3^3 + const ε = 216 / 24389; // 6^3/29^3 + const white = [0.96422, 1.0, 0.82521]; // D50 reference white + const f = []; + + // compute f, starting with the luminance-related term + f[1] = (Lab[0] + 16) / 116; + f[0] = Lab[1] / 500 + f[1]; + f[2] = f[1] - Lab[2] / 200; + + // compute xyz + const xyz = [ + Math.pow(f[0], 3) > ε ? Math.pow(f[0], 3) : (116 * f[0] - 16) / κ, + Lab[0] > κ * ε ? Math.pow((Lab[0] + 16) / 116, 3) : Lab[0] / κ, + Math.pow(f[2], 3) > ε ? Math.pow(f[2], 3) : (116 * f[2] - 16) / κ, + ]; + + // Compute XYZ by scaling xyz by reference white + return xyz.map((value, i) => value * white[i]) as Vec3; +} + +export function Lab_to_LCH(Lab: Vec3) { + // Convert to polar form + const hue = (Math.atan2(Lab[2], Lab[1]) * 180) / Math.PI; + return [ + Lab[0], // L is still L + Math.sqrt(Math.pow(Lab[1], 2) + Math.pow(Lab[2], 2)), // Chroma + hue >= 0 ? hue : hue + 360, // Hue, in degrees [0 to 360) + ] as Vec3; +} + +export function LCH_to_Lab(LCH: Vec3) { + // Convert from polar form + return [ + LCH[0], // L is still L + LCH[1] * Math.cos((LCH[2] * Math.PI) / 180), // a + LCH[1] * Math.sin((LCH[2] * Math.PI) / 180), // b + ] as Vec3; +} + +/** + * Converts an RGB color value to HSV. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSV_color_space. + * Assumes r, g, and b are contained in the set [0, 1] and + * returns h, s, and v in the set [0, 1]. + * + * @param rgb The red, green, and blue color values + * @return Array The HSV representation + */ +export function rgbToHsv(rgb: Vec3) { + const [r, g, b] = rgb; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h: number; + const v = max; + + const d = max - min; + const s = max === 0 ? 0 : d / max; + + if (max === min) { + h = 0; // achromatic + } else { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + + h = h! / 6; + } + + return [h, s, v] as Vec3; +} + +// utility functions for color conversions + +// [willshown]: Adjusted to export a TypeScript module. +// Retrieved on 24 May 2021 from https://drafts.csswg.org/css-color-4/utilities.js + +export function sRGB_to_luminance(RGB: Vec3) { + // convert an array of gamma-corrected sRGB values + // in the 0.0 to 1.0 range + // to linear-light sRGB, then to CIE XYZ + // and return luminance (the Y value) + + const XYZ = lin_sRGB_to_XYZ(lin_sRGB(RGB)); + return XYZ[1]; +} + +export function contrast(RGB1: Vec3, RGB2: Vec3) { + // return WCAG 2.1 contrast ratio + // https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio + // for two sRGB values + // given as arrays of 0.0 to 1.0 + + const L1 = sRGB_to_luminance(RGB1); + const L2 = sRGB_to_luminance(RGB2); + + if (L1 > L2) { + return (L1 + 0.05) / (L2 + 0.05); + } + + return (L2 + 0.05) / (L1 + 0.05); +} + +export function sRGB_to_LCH(RGB: Vec3) { + // convert an array of gamma-corrected sRGB values + // in the 0.0 to 1.0 range + // to linear-light sRGB, then to CIE XYZ, + // then adapt from D65 to D50, + // then convert XYZ to CIE Lab + // and finally, convert to CIE LCH + + return Lab_to_LCH(XYZ_to_Lab(D65_to_D50(lin_sRGB_to_XYZ(lin_sRGB(RGB))))); +} + +export function sRGB_to_LAB(RGB: Vec3) { + // convert an array of gamma-corrected sRGB values + // in the 0.0 to 1.0 range + // to linear-light sRGB, then to CIE XYZ, + // then adapt from D65 to D50, + // then convert XYZ to CIE Lab + + return XYZ_to_Lab(D65_to_D50(lin_sRGB_to_XYZ(lin_sRGB(RGB)))); +} + +export function P3_to_LCH(RGB: Vec3) { + // convert an array of gamma-corrected display-p3 values + // in the 0.0 to 1.0 range + // to linear-light display-p3, then to CIE XYZ, + // then adapt from D65 to D50, + // then convert XYZ to CIE Lab + // and finally, convert to CIE LCH + + return Lab_to_LCH(XYZ_to_Lab(D65_to_D50(lin_P3_to_XYZ(lin_P3(RGB))))); +} + +export function r2020_to_LCH(RGB: Vec3) { + // convert an array of gamma-corrected rec.2020 values + // in the 0.0 to 1.0 range + // to linear-light sRGB, then to CIE XYZ, + // then adapt from D65 to D50, + // then convert XYZ to CIE Lab + // and finally, convert to CIE LCH + + return Lab_to_LCH(XYZ_to_Lab(D65_to_D50(lin_2020_to_XYZ(lin_2020(RGB))))); +} + +export function LCH_to_sRGB(LCH: Vec3) { + // convert an array of CIE LCH values + // to CIE Lab, and then to XYZ, + // adapt from D50 to D65, + // then convert XYZ to linear-light sRGB + // and finally to gamma corrected sRGB + // for in-gamut colors, components are in the 0.0 to 1.0 range + // out of gamut colors may have negative components + // or components greater than 1.0 + // so check for that :) + + return gam_sRGB(XYZ_to_lin_sRGB(D50_to_D65(Lab_to_XYZ(LCH_to_Lab(LCH))))); +} + +export function LAB_to_sRGB(LAB: Vec3) { + // convert an array of CIE Lab values to XYZ, + // adapt from D50 to D65, + // then convert XYZ to linear-light sRGB + // and finally to gamma corrected sRGB + // for in-gamut colors, components are in the 0.0 to 1.0 range + // out of gamut colors may have negative components + // or components greater than 1.0 + // so check for that :) + + return gam_sRGB(XYZ_to_lin_sRGB(D50_to_D65(Lab_to_XYZ(LAB)))); +} + +export function LCH_to_P3(LCH: Vec3) { + // convert an array of CIE LCH values + // to CIE Lab, and then to XYZ, + // adapt from D50 to D65, + // then convert XYZ to linear-light display-p3 + // and finally to gamma corrected display-p3 + // for in-gamut colors, components are in the 0.0 to 1.0 range + // out of gamut colors may have negative components + // or components greater than 1.0 + // so check for that :) + + return gam_P3(XYZ_to_lin_P3(D50_to_D65(Lab_to_XYZ(LCH_to_Lab(LCH))))); +} + +export function LCH_to_r2020(LCH: Vec3) { + // convert an array of CIE LCH values + // to CIE Lab, and then to XYZ, + // adapt from D50 to D65, + // then convert XYZ to linear-light rec.2020 + // and finally to gamma corrected rec.2020 + // for in-gamut colors, components are in the 0.0 to 1.0 range + // out of gamut colors may have negative components + // or components greater than 1.0 + // so check for that :) + + return gam_2020(XYZ_to_lin_2020(D50_to_D65(Lab_to_XYZ(LCH_to_Lab(LCH))))); +} + +// this is straight from the CSS Color 4 spec + +export function hslToRgb(hue: number, sat: number, light: number) { + // For simplicity, this algorithm assumes that the hue has been normalized + // to a number in the half-open range [0, 6), and the saturation and lightness + // have been normalized to the range [0, 1]. It returns an array of three numbers + // representing the red, green, and blue channels of the colors, + // normalized to the range [0, 1] + const t2 = light <= 0.5 ? light * (sat + 1) : light + sat - light * sat; + const t1 = light * 2 - t2; + const r = hueToChannel(t1, t2, hue + 2); + const g = hueToChannel(t1, t2, hue); + const b = hueToChannel(t1, t2, hue - 2); + return [r, g, b] as Vec3; +} + +export function hueToChannel(t1: number, t2: number, hue: number): number { + if (hue < 0) { + hue += 6; + } + if (hue >= 6) { + hue -= 6; + } + + if (hue < 1) { + return (t2 - t1) * hue + t1; + } else if (hue < 3) { + return t2; + } else if (hue < 4) { + return (t2 - t1) * (4 - hue) + t1; + } else { + return t1; + } +} + +// These are the naive algorithms from CS Color 4 + +export function naive_CMYK_to_sRGB(CMYK: Vec4) { + // CMYK is an array of four values + // in the range [0.0, 1.0] + // the optput is an array of [RGB] + // also in the [0.0, 1.0] range + // because the naive algorithm does not generate out of gamut colors + // neither does it generate accurate simulations of practical CMYK colors + + const cyan = CMYK[0]; + const magenta = CMYK[1]; + const yellow = CMYK[2]; + const black = CMYK[3]; + + const red = 1 - Math.min(1, cyan * (1 - black) + black); + const green = 1 - Math.min(1, magenta * (1 - black) + black); + const blue = 1 - Math.min(1, yellow * (1 - black) + black); + + return [red, green, blue] as Vec3; +} + +export function naive_sRGB_to_CMYK(RGB: Vec3) { + // RGB is an arravy of three values + // in the range [0.0, 1.0] + // the output is an array of [CMYK] + // also in the [0.0, 1.0] range + // with maximum GCR and (I think) 200% TAC + // the naive algorithm does not generate out of gamut colors + // neither does it generate accurate simulations of practical CMYK colors + + const red = RGB[0]; + const green = RGB[1]; + const blue = RGB[2]; + + const black = 1 - Math.max(red, green, blue); + const cyan = black === 1.0 ? 0 : (1 - red - black) / (1 - black); + const magenta = black === 1.0 ? 0 : (1 - green - black) / (1 - black); + const yellow = black === 1.0 ? 0 : (1 - blue - black) / (1 - black); + + return [cyan, magenta, yellow, black] as Vec4; +} + +// Chromaticity utilities + +export function XYZ_to_xy(XYZ: Vec3) { + // Convert an array of three XYZ values + // to x,y chromaticity coordinates + + const X = XYZ[0]; + const Y = XYZ[1]; + const Z = XYZ[2]; + const sum = X + Y + Z; + return [X / sum, Y / sum] as Vec2; +} + +export function xy_to_uv(xy: Vec2) { + // convert an x,y chromaticity pair + // to u*,v* chromaticities + + const x = xy[0]; + const y = xy[1]; + const denom = -2 * x + 12 * y + 3; + return [(4 * x) / denom, (9 * y) / denom] as Vec2; +} + +export function XYZ_to_uv(XYZ: Vec3) { + // Convert an array of three XYZ values + // to u*,v* chromaticity coordinates + + const X = XYZ[0]; + const Y = XYZ[1]; + const Z = XYZ[2]; + const denom = X + 15 * Y + 3 * Z; + return [(4 * X) / denom, (9 * Y) / denom] as Vec2; +} + +// [willshown]: Truncated to export only relevant functions and adjusted to export a TypeScript +// module, some additional adjustments to remove alpha support. Retrieved on 24 May 2021 +// from https://raw.githubusercontent.com/LeaVerou/css.land/master/lch/lch.js + +function is_LCH_inside_sRGB(l: number, c: number, h: number): boolean { + const ε = 0.000005; + const rgb = LCH_to_sRGB([+l, +c, +h]); + return rgb.reduce((a: boolean, b: number) => a && b >= 0 - ε && b <= 1 + ε, true); +} + +export function snap_into_gamut(Lab: Vec3): Vec3 { + // Moves an LCH color into the sRGB gamut + // by holding the l and h steady, + // and adjusting the c via binary-search + // until the color is on the sRGB boundary. + + // .0001 chosen fairly arbitrarily as "close enough" + const ε = 0.0001; + + const LCH = Lab_to_LCH(Lab); + const l = LCH[0]; + let c = LCH[1]; + const h = LCH[2]; + + if (is_LCH_inside_sRGB(l, c, h)) { + return Lab; + } + + let hiC = c; + let loC = 0; + c /= 2; + + while (hiC - loC > ε) { + if (is_LCH_inside_sRGB(l, c, h)) { + loC = c; + } else { + hiC = c; + } + c = (hiC + loC) / 2; + } + + return LCH_to_Lab([l, c, h]); +} diff --git a/packages/react-components/theme-designer/src/colors/geometry.ts b/packages/react-components/theme-designer/src/colors/geometry.ts new file mode 100644 index 00000000000000..19ebec95762ceb --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/geometry.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Curve, CurvePath, Vec3 } from './types'; + +const curveResolution = 128; + +// Many of these functions are ported from ThreeJS, which is distributed under +// the MIT license. Retrieved from https://github.com/mrdoob/three.js on +// 14 October 2021. + +function distanceTo(v1: Vec3, v2: Vec3) { + return Math.sqrt(distanceToSquared(v1, v2)); +} + +function distanceToSquared(v1: Vec3, v2: Vec3) { + const dx = v1[0] - v2[0]; + const dy = v1[1] - v2[1]; + const dz = v1[2] - v2[2]; + return dx * dx + dy * dy + dz * dz; +} + +function equals(v1: Vec3, v2: Vec3) { + return v1[0] === v2[0] && v1[1] === v2[1] && v1[2] === v2[2]; +} + +function QuadraticBezierP0(t: number, p: number): number { + const k = 1 - t; + return k * k * p; +} + +function QuadraticBezierP1(t: number, p: number): number { + return 2 * (1 - t) * t * p; +} + +function QuadraticBezierP2(t: number, p: number): number { + return t * t * p; +} + +function QuadraticBezier(t: number, p0: number, p1: number, p2: number): number { + return QuadraticBezierP0(t, p0) + QuadraticBezierP1(t, p1) + QuadraticBezierP2(t, p2); +} + +function getPointOnCurve(curve: Curve, t: number) { + const [v0, v1, v2] = curve.points; + return [ + QuadraticBezier(t, v0[0], v1[0], v2[0]), + QuadraticBezier(t, v0[1], v1[1], v2[1]), + QuadraticBezier(t, v0[2], v1[2], v2[2]), + ] as Vec3; +} + +function getPointsOnCurve(curve: Curve, divisions: number): Vec3[] { + const points = []; + for (let d = 0; d <= divisions; d++) { + points.push(getPointOnCurve(curve, d / divisions)); + } + return points; +} + +function getCurvePathLength(curvePath: CurvePath) { + const lengths = getCurvePathLengths(curvePath); + return lengths[lengths.length - 1]; +} + +function getCurvePathLengths(curvePath: CurvePath) { + if (curvePath.cacheLengths && curvePath.cacheLengths.length === curvePath.curves.length) { + return curvePath.cacheLengths; + } + // Get length of sub-curve + // Push sums into cached array + const lengths = []; + let sums = 0; + for (let i = 0, l = curvePath.curves.length; i < l; i++) { + sums += getCurveLength(curvePath.curves[i]); + lengths.push(sums); + } + curvePath.cacheLengths = lengths; + return lengths; +} + +function getCurveLength(curve: Curve) { + const lengths = getCurveLengths(curve); + return lengths[lengths.length - 1]; +} + +function getCurveLengths(curve: Curve, divisions = curveResolution) { + if (curve.cacheArcLengths && curve.cacheArcLengths.length === divisions + 1) { + return curve.cacheArcLengths; + } + + const cache = []; + let current; + let last = getPointOnCurve(curve, 0); + let sum = 0; + + cache.push(0); + + for (let p = 1; p <= divisions; p++) { + current = getPointOnCurve(curve, p / divisions); + sum += distanceTo(current, last); + cache.push(sum); + last = current; + } + + curve.cacheArcLengths = cache; + + return cache; // { sums: cache, sum: sum }; Sum is in the last element. +} + +function getCurveUtoTMapping(curve: Curve, u: number, distance?: number) { + const arcLengths = getCurveLengths(curve); + let i = 0; + const il = arcLengths.length; + let targetArcLength; // The targeted u distance value to get + + if (distance) { + targetArcLength = distance; + } else { + targetArcLength = u * arcLengths[il - 1]; + } + + // binary search for the index with largest value smaller than target u distance + + let low = 0; + let high = il - 1; + let comparison; + + while (low <= high) { + i = Math.floor(low + (high - low) / 2); // less likely to overflow, though probably not issue here, JS doesn't really have integers, all numbers are floats + comparison = arcLengths[i] - targetArcLength; + + if (comparison < 0) { + low = i + 1; + } else if (comparison > 0) { + high = i - 1; + } else { + high = i; + break; + } + } + + i = high; + + if (arcLengths[i] === targetArcLength) { + return i / (il - 1); + } + + // we could get finer grain at lengths, or use simple interpolation between two points + const lengthBefore = arcLengths[i]; + const lengthAfter = arcLengths[i + 1]; + + const segmentLength = lengthAfter - lengthBefore; + + // determine where we are between the 'before' and 'after' points + const segmentFraction = (targetArcLength - lengthBefore) / segmentLength; + + // add that fractional amount to t + const t = (i + segmentFraction) / (il - 1); + + return t; +} + +function getPointOnCurveAt(curve: Curve, u: number) { + return getPointOnCurve(curve, getCurveUtoTMapping(curve, u)); +} + +export function getPointOnCurvePath(curvePath: CurvePath, t: number): Vec3 | null { + const d = t * getCurvePathLength(curvePath); + const curveLengths = getCurvePathLengths(curvePath); + let i = 0; + + while (i < curveLengths.length) { + if (curveLengths[i] >= d) { + const diff = curveLengths[i] - d; + const curve = curvePath.curves[i]; + + const segmentLength = getCurveLength(curve); + const u = segmentLength === 0 ? 0 : 1 - diff / segmentLength; + + return getPointOnCurveAt(curve, u); + } + i++; + } + return null; +} + +export function getPointsOnCurvePath(curvePath: CurvePath, divisions = curveResolution): Vec3[] { + const points = []; + let last; + + for (let i = 0, curves = curvePath.curves; i < curves.length; i++) { + const curve = curves[i]; + const pts = getPointsOnCurve(curve, divisions); + + for (const point of pts) { + if (last && equals(last, point)) { + // ensures no consecutive points are duplicates + continue; + } + + points.push(point); + last = point; + } + } + + return points; +} diff --git a/packages/react-components/theme-designer/src/colors/index.ts b/packages/react-components/theme-designer/src/colors/index.ts new file mode 100644 index 00000000000000..7cecca373ae30c --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/index.ts @@ -0,0 +1,5 @@ +export * from './csswg'; +export * from './geometry'; +export * from './palettes'; +export * from './templates'; +export * from './types'; diff --git a/packages/react-components/theme-designer/src/colors/palettes.ts b/packages/react-components/theme-designer/src/colors/palettes.ts new file mode 100644 index 00000000000000..6a10438d54c18b --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/palettes.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { LAB_to_sRGB, LCH_to_Lab, Lab_to_LCH, sRGB_to_LCH, snap_into_gamut } from './csswg'; +import { getPointsOnCurvePath } from './geometry'; +import { CurvedHelixPath, Palette, Vec3 } from './types'; + +// This file contains functions that combine geometry and color math to create +// and work with palette curves. + +/** + * When distributing output shades along the curve, for each shade’s lightness a + * logarithmically distributed value is averaged with a linearly distributed + * value to this degree between zero and one, zero meaning use the logarithmic + * value, one meaning use the linear value. + */ +const defaultLinearity = 0.75; + +function getLinearSpace(min: number, max: number, n: number) { + const result = []; + const delta = (max - min) / n; + for (let i = 0; i < n; i++) { + result[i] = min + delta * i; + } + return result; +} + +const getLogSpace = (min: number, max: number, n: number) => { + const a = min <= 0 ? 0 : Math.log(min); + const b = Math.log(max); + const delta = (b - a) / n; + + const result = [Math.pow(Math.E, a)]; + for (let i = 1; i < n; i += 1) { + result.push(Math.pow(Math.E, a + delta * i)); + } + result.push(Math.pow(Math.E, b)); + return result; +}; + +function paletteShadesFromCurvePoints( + curvePoints: Vec3[], + nShades: number, + range = [0, 100], + linearity = defaultLinearity, +): Vec3[] { + if (curvePoints.length <= 2) { + return []; + } + + const paletteShades = []; + + const logLightness = getLogSpace(Math.log10(range[0]), Math.log10(range[1]), nShades); + + const linearLightness = getLinearSpace(range[0], range[1], nShades); + + let c = 0; + + for (let i = 0; i < nShades; i++) { + const l = Math.min( + range[1], + Math.max(range[0], logLightness[i] * (1 - linearity) + linearLightness[i] * linearity), + ); + + while (l > curvePoints[c + 1][0]) { + c++; + } + + const [l1, a1, b1] = curvePoints[c]; + const [l2, a2, b2] = curvePoints[c + 1]; + + const u = (l - l1) / (l2 - l1); + + paletteShades[i] = [l1 + (l2 - l1) * u, a1 + (a2 - a1) * u, b1 + (b2 - b1) * u] as Vec3; + } + + return paletteShades.map(snap_into_gamut); +} + +export function paletteShadesFromCurve( + curve: CurvedHelixPath, + nShades = 16, + range = [0, 100], + linearity = defaultLinearity, + curveDepth = 24, +): Vec3[] { + return paletteShadesFromCurvePoints( + getPointsOnCurvePath(curve, Math.ceil((curveDepth * (1 + Math.abs(curve.torsion || 1))) / 2)).map( + (curvePoint: Vec3) => getPointOnHelix(curvePoint, curve.torsion, curve.torsionT0), + ), + nShades, + range, + linearity, + ); +} + +export function sRGB_to_hex(rgb: Vec3): string { + return `#${rgb + .map(x => { + const channel = x < 0 ? 0 : Math.floor(x >= 1.0 ? 255 : x * 256); + return channel.toString(16).padStart(2, '0'); + }) + .join('')}`; +} + +export function Lab_to_hex(lab: Vec3): string { + return sRGB_to_hex(LAB_to_sRGB(lab)); +} + +export function hex_to_sRGB(hex: string): Vec3 { + const aRgbHex = hex.match(/#?(..)(..)(..)/); + return aRgbHex + ? [parseInt(aRgbHex[1], 16) / 255, parseInt(aRgbHex[2], 16) / 255, parseInt(aRgbHex[3], 16) / 255] + : [0, 0, 0]; +} + +export function hex_to_LCH(hex: string): Vec3 { + return sRGB_to_LCH(hex_to_sRGB(hex)); +} + +function paletteShadesToHex(paletteShades: Vec3[]): string[] { + return paletteShades.map(Lab_to_hex); +} + +function getPointOnHelix(pointOnCurve: Vec3, torsion = 0, torsionT0 = 50): Vec3 { + const t = pointOnCurve[0]; + const [l, c, h] = Lab_to_LCH(pointOnCurve); + const hueOffset = torsion * (t - torsionT0); + return LCH_to_Lab([l, c, h + hueOffset]); +} + +// function getPointOnCurvedHelixPathWithinGamut(curvedHelixPath: CurvedHelixPath, t: number): Vec3 { +// return snap_into_gamut( +// getPointOnHelix(getPointOnCurvePath(curvedHelixPath, t)!, curvedHelixPath.torsion, curvedHelixPath.torsionT0), +// ); +// } + +export function curvePathFromPalette({ keyColor, darkCp, lightCp, hueTorsion }: Palette): CurvedHelixPath { + const blackPosition = [0, 0, 0]; + const whitePosition = [100, 0, 0]; + const keyColorPosition = LCH_to_Lab(keyColor); + const [l, a, b] = keyColorPosition; + + const darkControlPosition = [l * (1 - darkCp), a, b]; + const lightControlPosition = [l + (100 - l) * lightCp, a, b]; + + return { + curves: [ + { points: [blackPosition, darkControlPosition, keyColorPosition] }, + { points: [keyColorPosition, lightControlPosition, whitePosition] }, + ], + torsion: hueTorsion, + torsionT0: l, + } as CurvedHelixPath; +} + +export function cssGradientFromCurve( + curve: CurvedHelixPath, + nShades = 16, + range = [0, 100], + linearity = defaultLinearity, + curveDepth = 24, +) { + const hexes = paletteShadesToHex(paletteShadesFromCurve(curve, nShades, range, linearity, curveDepth)); + return `linear-gradient(to right, ${hexes.join(', ')})`; +} + +export function hexColorsFromPalette( + palette: Palette, + nShades = 16, + range = [0, 100], + linearity = defaultLinearity, + curveDepth = 24, +): string[] { + return paletteShadesToHex( + paletteShadesFromCurve(curvePathFromPalette(palette), nShades, range, linearity, curveDepth), + ); +} diff --git a/packages/react-components/theme-designer/src/colors/templates.ts b/packages/react-components/theme-designer/src/colors/templates.ts new file mode 100644 index 00000000000000..08eb5231596174 --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/templates.ts @@ -0,0 +1,17 @@ +import { NamedPalette, NamedTheme } from './types'; + +export const paletteTemplate = (id: string): NamedPalette & { id: string } => ({ + id, + name: '', + keyColor: [44.51, 39.05, 288.84], + darkCp: 2 / 3, + lightCp: 1 / 3, + hueTorsion: 0, +}); + +export const themeTemplate = (id: string): NamedTheme & { id: string } => ({ + id, + name: '', + backgrounds: {}, + foregrounds: {}, +}); diff --git a/packages/react-components/theme-designer/src/colors/types.ts b/packages/react-components/theme-designer/src/colors/types.ts new file mode 100644 index 00000000000000..dee880d18a740a --- /dev/null +++ b/packages/react-components/theme-designer/src/colors/types.ts @@ -0,0 +1,62 @@ +export type Vec2 = [number, number]; +export type Vec3 = [number, number, number]; +export type Vec4 = [number, number, number, number]; + +export type Curve = { + points: [Vec3, Vec3, Vec3]; + cacheArcLengths?: number[]; +}; + +export interface CurvePath { + curves: Curve[]; + cacheLengths?: number[]; +} + +export interface CurvedHelixPath extends CurvePath { + torsion?: number; + torsionT0?: number; +} + +export type Palette = { + keyColor: Vec3; + darkCp: number; + lightCp: number; + hueTorsion: number; +}; + +export type NamedPalette = Palette & { name: string }; + +export type PaletteConfig = { + range: [number, number]; + nShades: number; + linearity?: number; + shadeNames?: Record; +}; + +export type Theme = { + backgrounds: { + [paletteId: string]: PaletteConfig; + }; + foregrounds: { + [paletteId: string]: PaletteConfig; + }; +}; + +export type NamedTheme = Theme & { name: string }; + +export type TokenPackageType = 'csscp' | 'json'; + +export interface ThemeCollectionInclude { + [paletteId: string]: number[]; +} + +export type TokenPackageConfig = { + type: TokenPackageType; + selector: string; + include: { + [themeId: string]: { + backgrounds: ThemeCollectionInclude; + foregrounds: ThemeCollectionInclude; + }; + }; +}; diff --git a/packages/react-components/theme-designer/src/components/ColorTokens/getAccessibilityChecker.ts b/packages/react-components/theme-designer/src/components/ColorTokens/getAccessibilityChecker.ts index 5236d01f9ae9a7..7907878e91b07f 100644 --- a/packages/react-components/theme-designer/src/components/ColorTokens/getAccessibilityChecker.ts +++ b/packages/react-components/theme-designer/src/components/ColorTokens/getAccessibilityChecker.ts @@ -1,4 +1,4 @@ -import { contrast, hex_to_sRGB, Vec3 } from '@fluent-blocks/colors'; +import { contrast, hex_to_sRGB, Vec3 } from '../../colors'; import { Theme } from '@fluentui/react-components'; import { accessiblePairs } from './AccessiblePairs'; diff --git a/packages/react-components/theme-designer/src/components/Palette/Palette.tsx b/packages/react-components/theme-designer/src/components/Palette/Palette.tsx index a92d544eec1170..d56cd992c06a6b 100644 --- a/packages/react-components/theme-designer/src/components/Palette/Palette.tsx +++ b/packages/react-components/theme-designer/src/components/Palette/Palette.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { makeStyles, mergeClasses } from '@griffel/react'; import { Button, Caption1, Text } from '@fluentui/react-components'; import { Brands, BrandVariants } from '@fluentui/react-theme'; -import { contrast, hex_to_sRGB } from '@fluent-blocks/colors'; +import { contrast, hex_to_sRGB } from '../../colors'; import { bundleIcon, CopyFilled, CopyRegular } from '@fluentui/react-icons'; import { AppContext } from '../../ThemeDesigner'; import { useContextSelector } from '@fluentui/react-context-selector'; diff --git a/packages/react-components/theme-designer/src/utils/getBrandTokensFromPalette.ts b/packages/react-components/theme-designer/src/utils/getBrandTokensFromPalette.ts index c4aaf778844250..3bb62c703f18f0 100644 --- a/packages/react-components/theme-designer/src/utils/getBrandTokensFromPalette.ts +++ b/packages/react-components/theme-designer/src/utils/getBrandTokensFromPalette.ts @@ -1,5 +1,5 @@ import { BrandVariants } from '@fluentui/react-theme'; -import { Palette, hexColorsFromPalette, hex_to_LCH } from '@fluent-blocks/colors'; +import { Palette, hexColorsFromPalette, hex_to_LCH } from '../colors'; type Options = { darkCp?: number; diff --git a/yarn.lock b/yarn.lock index 61ccbdaa72b66c..1cadbb40c887af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1630,11 +1630,6 @@ dependencies: "@floating-ui/core" "^1.2.0" -"@fluent-blocks/colors@9.2.0": - version "9.2.0" - resolved "https://registry.yarnpkg.com/@fluent-blocks/colors/-/colors-9.2.0.tgz#8aeb30f93f5f827b2842b9cff43541a87e304e18" - integrity sha512-NgK+n4IHRj35ttJjN3UBF8oqk2ZT8xwCdT52+nTXvVSL5yHDtKwQlgb+zvrNFhYNajbqEeqYdj4QA3XSkytLww== - "@fluentui/dom-utilities@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@fluentui/dom-utilities/-/dom-utilities-1.1.1.tgz#b0bbab665fe726f245800bb9e7883b1ceb54248b"