+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showEnvironmentTitle && environmentTitle.length ? environmentTitle : "ECL Watch"}
+
+
+
+
+
- }
-
- 0 ? "RingerSolid" : "Ringer" }} className={btnStyles.errorsWarnings}>
-
-
-
-
-
-
-
-
-
-
+
+ { window.location.href = `#/search/${newValue.trim()}`; }} placeholder={nlsHPCC.PlaceholderFindText} styles={{ root: { minWidth: 320 } }} />
+
+
+
+ {currentUser?.username &&
+
+ setShowMyAccount(true)} />
+
+ }
+
+ 0 ? "RingerSolid" : "Ringer" }} className={btnStyles.errorsWarnings}>
+
+
+
+
+
+
+
+
+
+
+
+
+
setShowAbout(false)} >
setShowMyAccount(false)}>
diff --git a/esp/src/src-react/components/forms/TitlebarConfig.tsx b/esp/src/src-react/components/forms/TitlebarConfig.tsx
index b0682975468..ce16b27d98f 100644
--- a/esp/src/src-react/components/forms/TitlebarConfig.tsx
+++ b/esp/src/src-react/components/forms/TitlebarConfig.tsx
@@ -1,8 +1,10 @@
import * as React from "react";
-import { Checkbox, ColorPicker, DefaultButton, getColorFromString, IColor, Label, PrimaryButton, TextField, TooltipHost } from "@fluentui/react";
+import { Checkbox, DefaultButton, getColorFromString, IColor, PrimaryButton, TextField, TooltipHost } from "@fluentui/react";
import { useForm, Controller } from "react-hook-form";
+import { ThemeEditorColorPicker } from "../ThemeEditor";
import { MessageBox } from "../../layouts/MessageBox";
import { useGlobalStore } from "../../hooks/store";
+import { useToolbarTheme } from "../../hooks/theme";
import nlsHPCC from "src/nlsHPCC";
@@ -24,38 +26,41 @@ interface TitlebarConfigProps {
setShowForm: (_: boolean) => void;
}
-const white = getColorFromString("#ffffff");
-
export const TitlebarConfig: React.FunctionComponent
= ({
toolbarThemeDefaults,
showForm,
setShowForm
}) => {
const { handleSubmit, control, reset } = useForm({ defaultValues });
- const [color, setColor] = React.useState(white);
- const updateColor = React.useCallback((evt: any, colorObj: IColor) => setColor(colorObj), []);
+ const { primaryColor, setPrimaryColor } = useToolbarTheme();
+ const [previousColor, setPreviousColor] = React.useState(primaryColor);
const [showEnvironmentTitle, setShowEnvironmentTitle] = useGlobalStore("HPCCPlatformWidget_Toolbar_Active", toolbarThemeDefaults.active, true);
const [environmentTitle, setEnvironmentTitle] = useGlobalStore("HPCCPlatformWidget_Toolbar_Text", toolbarThemeDefaults.text, true);
- const [titlebarColor, setTitlebarColor] = useGlobalStore("HPCCPlatformWidget_Toolbar_Color", toolbarThemeDefaults.color, true);
const closeForm = React.useCallback(() => {
setShowForm(false);
}, [setShowForm]);
+ React.useEffect(() => {
+ // cache the previous color at dialog open, used to reset upon close
+ if (showForm) setPreviousColor(primaryColor);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [showForm]);
+
const onSubmit = React.useCallback(() => {
handleSubmit(
(data, evt) => {
const request: any = data;
- request.titlebarColor = color.str;
+ request.titlebarColor = primaryColor;
setShowEnvironmentTitle(request?.showEnvironmentTitle);
setEnvironmentTitle(request?.environmentTitle);
- setTitlebarColor(request.titlebarColor);
+ setPrimaryColor(request.titlebarColor);
closeForm();
},
)();
- }, [closeForm, color, handleSubmit, setEnvironmentTitle, setShowEnvironmentTitle, setTitlebarColor]);
+ }, [closeForm, primaryColor, handleSubmit, setEnvironmentTitle, setShowEnvironmentTitle, setPrimaryColor]);
const [, , resetShowEnvironmentTitle] = useGlobalStore("HPCCPlatformWidget_Toolbar_Active", toolbarThemeDefaults.active, true);
const [, , resetEnvironmentTitle] = useGlobalStore("HPCCPlatformWidget_Toolbar_Text", toolbarThemeDefaults.text, true);
@@ -68,18 +73,17 @@ export const TitlebarConfig: React.FunctionComponent = ({
}, [resetEnvironmentTitle, resetShowEnvironmentTitle, resetTitlebarColor]);
React.useEffect(() => {
- setColor(getColorFromString(titlebarColor));
const values = {
showEnvironmentTitle: showEnvironmentTitle,
environmentTitle: environmentTitle || ""
};
reset(values);
- }, [environmentTitle, reset, showEnvironmentTitle, titlebarColor]);
+ }, [environmentTitle, reset, showEnvironmentTitle]);
return
- { reset(defaultValues); closeForm(); }} />
+ { setPrimaryColor(previousColor); reset(defaultValues); closeForm(); }} />
{ onReset(); }} />
>}>
= ({
/>
-
-
+
+ {
+ setPrimaryColor(newColor.str);
+ }}
+ label={nlsHPCC.ToolbarColor}
+ />
+
;
diff --git a/esp/src/src-react/hooks/theme.ts b/esp/src/src-react/hooks/theme.ts
index e1f909a456a..2db4342ac61 100644
--- a/esp/src/src-react/hooks/theme.ts
+++ b/esp/src/src-react/hooks/theme.ts
@@ -1,17 +1,138 @@
+import * as React from "react";
import { Theme } from "@fluentui/react";
-import { Theme as ThemeV9 } from "@fluentui/react-components";
+import { createDarkTheme, createLightTheme, Theme as ThemeV9 } from "@fluentui/react-components";
+import { createV8Theme } from "@fluentui/react-migration-v8-v9";
import { darkTheme, lightTheme, darkThemeV9, lightThemeV9 } from "../themes";
-import { useUserStore } from "./store";
+import { getBrandTokensFromPalette } from "../util/theme/getBrandTokensFromPalette";
+import { useUserStore, useGlobalStore } from "./store";
+import * as Utility from "../../src/Utility";
-export function useUserTheme(): { theme: Theme, themeV9: ThemeV9, setTheme: (value: "light" | "dark") => void, isDark: boolean } {
+interface UserThemeHook {
+ theme: Theme;
+ themeV9: ThemeV9;
+ setTheme: (theme: Theme) => void;
+ primaryColor: string;
+ setPrimaryColor: (color: string) => void;
+ hueTorsion?: number;
+ setHueTorsion?: (torsion: number) => void;
+ vibrancy?: number;
+ setVibrancy?: (vibrancy: number) => void;
+ setThemeDark: (value: "light" | "dark") => void;
+ isDark: boolean;
+ resetTheme: () => void;
+}
+
+const DEFAULT_HUE_TORSION = 0;
+const DEFAULT_VIBRANCY = 0;
+
+export function useUserTheme(): UserThemeHook {
+
+ const [themeDark, setThemeDark] = useUserStore("theme", "light", true);
+ const [hueTorsion, setHueTorsion] = useUserStore("theme_hueTorsion", DEFAULT_HUE_TORSION, true);
+ const [vibrancy, setVibrancy] = useUserStore("theme_vibrancy", DEFAULT_VIBRANCY, true);
+ const [theme, setTheme] = React.useState();
+ const [themeV9, setThemeV9] = React.useState();
+ const [primaryColor, setPrimaryColor] = useUserStore("theme_primaryColor", themeV9?.colorBrandForeground1 ?? "", true);
+ const [primaryColorDark, setPrimaryColorDark] = useUserStore("theme_primaryColorDark", themeV9?.colorBrandForeground1 ?? "", true);
- const [theme, setTheme] = useUserStore("theme", "light", true);
+ const resetTheme = React.useCallback(() => {
+ if (themeDark === "dark") {
+ setPrimaryColorDark(darkTheme.palette.themePrimary);
+ } else {
+ setPrimaryColor(lightTheme.palette.themePrimary);
+ }
+ setHueTorsion(DEFAULT_HUE_TORSION);
+ setVibrancy(DEFAULT_VIBRANCY);
+ }, [setHueTorsion, setPrimaryColor, setPrimaryColorDark, setVibrancy, themeDark]);
+
+ React.useEffect(() => {
+ let tokens;
+ let theme9: ThemeV9;
+ if (themeDark === "dark") {
+ if (!primaryColorDark) return;
+ tokens = getBrandTokensFromPalette(primaryColorDark, {
+ hueTorsion: hueTorsion ? hueTorsion / 20 : 0,
+ darkCp: vibrancy ? vibrancy / 50 : 2 / 3,
+ lightCp: vibrancy ? vibrancy / 50 : 1 / 3,
+ });
+ theme9 = createDarkTheme(tokens);
+ } else {
+ if (!primaryColor) return;
+ tokens = getBrandTokensFromPalette(primaryColor, {
+ hueTorsion: hueTorsion ? hueTorsion / 20 : 0,
+ darkCp: vibrancy ? vibrancy / 50 : 2 / 3,
+ lightCp: vibrancy ? vibrancy / 50 : 1 / 3,
+ });
+ theme9 = createLightTheme(tokens);
+ }
+ if (tokens && theme9) {
+ const theme8 = createV8Theme(tokens, theme9, themeDark === "dark");
+ setTheme(theme8);
+ setThemeV9(theme9);
+ }
+ }, [primaryColor, primaryColorDark, themeDark, hueTorsion, vibrancy]);
return {
- theme: theme === "dark" ? darkTheme : lightTheme,
- themeV9: theme === "dark" ? darkThemeV9 : lightThemeV9,
- setTheme: (value: "light" | "dark") => setTheme(value),
- isDark: theme === "dark"
+ theme: theme ?? (themeDark === "dark" ? darkTheme : lightTheme),
+ themeV9: themeV9 ?? (themeDark === "dark" ? darkThemeV9 : lightThemeV9),
+ setTheme: setTheme,
+ primaryColor: themeDark === "dark" ? primaryColorDark : primaryColor,
+ setPrimaryColor: themeDark === "dark" ? setPrimaryColorDark : setPrimaryColor,
+ hueTorsion,
+ setHueTorsion,
+ vibrancy,
+ setVibrancy,
+ setThemeDark: setThemeDark,
+ isDark: themeDark === "dark",
+ resetTheme: resetTheme
};
}
+interface ToolbarThemeHook {
+ toolbarTheme: Theme;
+ toolbarThemeV9: ThemeV9;
+ resetToolbarTheme: () => void;
+ primaryColor: string;
+ setPrimaryColor: (color: string) => void;
+}
+
+export function useToolbarTheme(): ToolbarThemeHook {
+
+ const { isDark, themeV9 } = useUserTheme();
+ const [primaryColor, setPrimaryColor, resetPrimaryColor] = useGlobalStore("HPCCPlatformWidget_Toolbar_Color", themeV9.colorBrandBackground, true);
+
+ const [toolbarTheme, setToolbarTheme] = React.useState();
+ const [toolbarThemeV9, setToolbarThemeV9] = React.useState();
+
+ const resetTheme = React.useCallback(() => {
+ resetPrimaryColor();
+ }, [resetPrimaryColor]);
+
+ React.useEffect(() => {
+ if (!primaryColor) return;
+ const tokens = getBrandTokensFromPalette(primaryColor);
+ const theme9: ThemeV9 = createLightTheme(tokens);
+ // swap a background color to the selected color, so the banner will be the actual color picked
+ theme9.colorBrandBackground2 = primaryColor;
+ theme9.colorBrandForegroundLink = Utility.textColor(primaryColor);
+ if (tokens && theme9) {
+ const theme8 = createV8Theme(tokens, theme9, false);
+ setToolbarTheme(theme8);
+ setToolbarThemeV9(theme9);
+ }
+ }, [primaryColor]);
+
+ React.useEffect(() => {
+ if (!primaryColor) {
+ resetTheme();
+ }
+ }, [primaryColor, resetTheme]);
+
+ return {
+ toolbarTheme: toolbarTheme ?? (isDark ? darkTheme : lightTheme),
+ toolbarThemeV9: toolbarThemeV9 ?? (isDark ? darkThemeV9 : lightThemeV9),
+ primaryColor,
+ setPrimaryColor,
+ resetToolbarTheme: resetTheme
+ };
+}
diff --git a/esp/src/src-react/themes.ts b/esp/src/src-react/themes.ts
index 32cc98d1b52..84f45fcc0a0 100644
--- a/esp/src/src-react/themes.ts
+++ b/esp/src/src-react/themes.ts
@@ -32,6 +32,14 @@ const lightThemeOld: PartialTheme = {
neutralDark: "#222222",
black: "#000000",
white: "#ffffff",
+ },
+ semanticColors: {
+ infoIcon: "#605e5c",
+ errorIcon: "#A80000",
+ blockingIcon: "#FDE7E9",
+ warningIcon: "#D1B331",
+ severeWarningIcon: "#D83B01",
+ successIcon: "#107C10",
}
};
@@ -59,6 +67,14 @@ const darkThemeOld: PartialTheme = {
neutralDark: "#f0f0f0",
black: "#ffffff",
white: "#222222",
+ },
+ semanticColors: {
+ infoIcon: "#605e5c",
+ errorIcon: "#A80000",
+ blockingIcon: "#FDE7E9",
+ warningIcon: "#D1B331",
+ severeWarningIcon: "#D83B01",
+ successIcon: "#107C10",
}
};
@@ -136,8 +152,8 @@ namespace current {
namespace next {
export const lightThemeV9 = createLightTheme(brand);
export const darkThemeV9 = createDarkTheme(brand);
- export const lightTheme = createV8Theme(brand, lightThemeV9, false, current.lightTheme);
- export const darkTheme = createV8Theme(brand, darkThemeV9, true, current.darkTheme);
+ export const lightTheme = createV8Theme(brand, lightThemeV9, false);
+ export const darkTheme = createV8Theme(brand, darkThemeV9, true);
}
const useNext = false;
diff --git a/esp/src/src-react/util/theme/colors/csswg.ts b/esp/src/src-react/util/theme/colors/csswg.ts
new file mode 100644
index 00000000000..9f34e63516a
--- /dev/null
+++ b/esp/src/src-react/util/theme/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/esp/src/src-react/util/theme/colors/geometry.ts b/esp/src/src-react/util/theme/colors/geometry.ts
new file mode 100644
index 00000000000..ee2c7de8bdf
--- /dev/null
+++ b/esp/src/src-react/util/theme/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/esp/src/src-react/util/theme/colors/hueMap.ts b/esp/src/src-react/util/theme/colors/hueMap.ts
new file mode 100644
index 00000000000..7977bd99fbd
--- /dev/null
+++ b/esp/src/src-react/util/theme/colors/hueMap.ts
@@ -0,0 +1,397 @@
+export function hexToHue(hexColor: string) {
+ // Parse the hex color string into its red, green, and blue components
+ const red = parseInt(hexColor.substring(1, 3), 16);
+ const green = parseInt(hexColor.substring(3, 5), 16);
+ const blue = parseInt(hexColor.substring(5, 7), 16);
+
+ // Convert the RGB color to HSL color space
+ const r = red / 255;
+ const g = green / 255;
+ const b = blue / 255;
+ const cmax = Math.max(r, g, b);
+ const cmin = Math.min(r, g, b);
+ const delta = cmax - cmin;
+ let hue;
+
+ // Calculate the hue value based on the RGB color values
+ if (delta === 0) {
+ hue = 0;
+ } else if (cmax === r) {
+ hue = ((g - b) / delta) % 6;
+ } else if (cmax === g) {
+ hue = (b - r) / delta + 2;
+ } else {
+ hue = (r - g) / delta + 4;
+ }
+
+ // Convert the hue value to degrees and return it
+ hue = Math.round(hue * 60);
+ if (hue < 0) {
+ hue += 360;
+ }
+ return hue;
+}
+
+// map of hue to [min, center, max], generated from Arman
+export const hueToSnappingPointsMap = [
+ [0.0085504, 0.148504, 0.858504],
+ [0.00855388, 0.1485388, 0.8585388],
+ [0.0085582, 0.148582, 0.858582],
+ [0.00856192, 0.1486192, 0.8586192],
+ [0.00856644, 0.1486644, 0.8586644],
+ [0.00857184, 0.1487184, 0.8587184],
+ [0.0085802, 0.148802, 0.858802],
+ [0.00858752, 0.1488752, 0.8588752],
+ [0.00859616, 0.1489616, 0.8589616],
+ [0.00860584, 0.1490584, 0.8590584],
+ [0.00861948, 0.1491948, 0.8591948],
+ [0.00863172, 0.1493172, 0.8593172],
+ [0.00864508, 0.1494508, 0.8594508],
+ [0.00865968, 0.1495968, 0.8595968],
+ [0.00867968, 0.1497968, 0.8597968],
+ [0.00869708, 0.1499708, 0.8599708],
+ [0.00871576, 0.1501576, 0.8601576],
+ [0.0087358, 0.150358, 0.860358],
+ [0.00876272, 0.1506272, 0.8606272],
+ [0.0087858, 0.150858, 0.860858],
+ [0.00881028, 0.1511028, 0.8611028],
+ [0.0088362, 0.151362, 0.861362],
+ [0.0088706, 0.151706, 0.861706],
+ [0.0088998, 0.151998, 0.861998],
+ [0.00893052, 0.1523052, 0.8623052],
+ [0.00896272, 0.1526272, 0.8626272],
+ [0.00900516, 0.1530516, 0.8630516],
+ [0.00904084, 0.1534084, 0.8634084],
+ [0.00907812, 0.1537812, 0.8637812],
+ [0.00911704, 0.1541704, 0.8641704],
+ [0.00916792, 0.1546792, 0.8646792],
+ [0.00921048, 0.1551048, 0.8651048],
+ [0.00925472, 0.1555472, 0.8655472],
+ [0.00930064, 0.1560064, 0.8660064],
+ [0.00934824, 0.1564824, 0.8664824],
+ [0.0094102, 0.157102, 0.867102],
+ [0.00946168, 0.1576168, 0.8676168],
+ [0.00951496, 0.1581496, 0.8681496],
+ [0.00957, 0.1587, 0.8687],
+ [0.00964128, 0.1594128, 0.8694128],
+ [0.00970036, 0.1600036, 0.8700036],
+ [0.00976128, 0.1606128, 0.8706128],
+ [0.00982404, 0.1612404, 0.8712404],
+ [0.00990508, 0.1620508, 0.8720508],
+ [0.009972, 0.16272, 0.87272],
+ [0.01004084, 0.1634084, 0.8734084],
+ [0.0101296, 0.164296, 0.874296],
+ [0.01020272, 0.1650272, 0.8750272],
+ [0.01027784, 0.1657784, 0.8757784],
+ [0.01035488, 0.1665488, 0.8765488],
+ [0.01045392, 0.1675392, 0.8775392],
+ [0.0105354, 0.168354, 0.878354],
+ [0.01061892, 0.1691892, 0.8791892],
+ [0.0107044, 0.170044, 0.880044],
+ [0.01081412, 0.1711412, 0.8811412],
+ [0.0109042, 0.172042, 0.882042],
+ [0.01099636, 0.1729636, 0.8829636],
+ [0.01109056, 0.1739056, 0.8839056],
+ [0.01121124, 0.1751124, 0.8851124],
+ [0.01131016, 0.1761016, 0.8861016],
+ [0.0114112, 0.177112, 0.887112],
+ [0.01138116, 0.1768116, 0.8868116],
+ [0.01135176, 0.1765176, 0.8865176],
+ [0.01131588, 0.1761588, 0.8861588],
+ [0.01128788, 0.1758788, 0.8858788],
+ [0.01126048, 0.1756048, 0.8856048],
+ [0.01122712, 0.1752712, 0.8852712],
+ [0.01120108, 0.1750108, 0.8850108],
+ [0.01117568, 0.1747568, 0.8847568],
+ [0.01115088, 0.1745088, 0.8845088],
+ [0.01112068, 0.1742068, 0.8842068],
+ [0.0110972, 0.173972, 0.883972],
+ [0.01107428, 0.1737428, 0.8837428],
+ [0.01105196, 0.1735196, 0.8835196],
+ [0.01102488, 0.1732488, 0.8832488],
+ [0.01100384, 0.1730384, 0.8830384],
+ [0.0109834, 0.172834, 0.882834],
+ [0.01096348, 0.1726348, 0.8826348],
+ [0.01094416, 0.1724416, 0.8824416],
+ [0.01092072, 0.1722072, 0.8822072],
+ [0.01090264, 0.1720264, 0.8820264],
+ [0.01088508, 0.1718508, 0.8818508],
+ [0.01086388, 0.1716388, 0.8816388],
+ [0.01084752, 0.1714752, 0.8814752],
+ [0.01083168, 0.1713168, 0.8813168],
+ [0.01081636, 0.1711636, 0.8811636],
+ [0.010798, 0.17098, 0.88098],
+ [0.0107838, 0.170838, 0.880838],
+ [0.01077016, 0.1707016, 0.8807016],
+ [0.010757, 0.17057, 0.88057],
+ [0.01074436, 0.1704436, 0.8804436],
+ [0.01072924, 0.1702924, 0.8802924],
+ [0.01071768, 0.1701768, 0.8801768],
+ [0.01070656, 0.1700656, 0.8800656],
+ [0.010696, 0.16996, 0.87996],
+ [0.01068336, 0.1698336, 0.8798336],
+ [0.01067376, 0.1697376, 0.8797376],
+ [0.01066464, 0.1696464, 0.8796464],
+ [0.010656, 0.16956, 0.87956],
+ [0.01064572, 0.1694572, 0.8794572],
+ [0.01063804, 0.1693804, 0.8793804],
+ [0.01063076, 0.1693076, 0.8793076],
+ [0.01062224, 0.1692224, 0.8792224],
+ [0.01061588, 0.1691588, 0.8791588],
+ [0.01060996, 0.1690996, 0.8790996],
+ [0.0106044, 0.169044, 0.879044],
+ [0.0105992, 0.168992, 0.878992],
+ [0.01059328, 0.1689328, 0.8789328],
+ [0.01058892, 0.1688892, 0.8788892],
+ [0.01058496, 0.1688496, 0.8788496],
+ [0.01058132, 0.1688132, 0.8788132],
+ [0.01057728, 0.1687728, 0.8787728],
+ [0.0105744, 0.168744, 0.878744],
+ [0.01057184, 0.1687184, 0.8787184],
+ [0.01056956, 0.1686956, 0.8786956],
+ [0.01056716, 0.1686716, 0.8786716],
+ [0.01056556, 0.1686556, 0.8786556],
+ [0.0105642, 0.168642, 0.878642],
+ [0.01056284, 0.1686284, 0.8786284],
+ [0.0105618, 0.168618, 0.878618],
+ [0.0105608, 0.168608, 0.878608],
+ [0.01056112, 0.1686112, 0.8786112],
+ [0.01056148, 0.1686148, 0.8786148],
+ [0.01056196, 0.1686196, 0.8786196],
+ [0.0105624, 0.168624, 0.878624],
+ [0.01056296, 0.1686296, 0.8786296],
+ [0.0105636, 0.168636, 0.878636],
+ [0.01056452, 0.1686452, 0.8786452],
+ [0.0105654, 0.168654, 0.878654],
+ [0.0105664, 0.168664, 0.878664],
+ [0.01056748, 0.1686748, 0.8786748],
+ [0.010569, 0.16869, 0.87869],
+ [0.01057036, 0.1687036, 0.8787036],
+ [0.0105718, 0.168718, 0.878718],
+ [0.01057384, 0.1687384, 0.8787384],
+ [0.0105756, 0.168756, 0.878756],
+ [0.01057748, 0.1687748, 0.8787748],
+ [0.01057948, 0.1687948, 0.8787948],
+ [0.01058164, 0.1688164, 0.8788164],
+ [0.01058456, 0.1688456, 0.8788456],
+ [0.010587, 0.16887, 0.87887],
+ [0.01058964, 0.1688964, 0.8788964],
+ [0.0105924, 0.168924, 0.878924],
+ [0.01059604, 0.1689604, 0.8789604],
+ [0.01059916, 0.1689916, 0.8789916],
+ [0.0106024, 0.169024, 0.879024],
+ [0.01060668, 0.1690668, 0.8790668],
+ [0.01061028, 0.1691028, 0.8791028],
+ [0.01061408, 0.1691408, 0.8791408],
+ [0.010618, 0.16918, 0.87918],
+ [0.01062312, 0.1692312, 0.8792312],
+ [0.01062744, 0.1692744, 0.8792744],
+ [0.01063188, 0.1693188, 0.8793188],
+ [0.01063652, 0.1693652, 0.8793652],
+ [0.01064132, 0.1694132, 0.8794132],
+ [0.0106476, 0.169476, 0.879476],
+ [0.0106528, 0.169528, 0.879528],
+ [0.01065816, 0.1695816, 0.8795816],
+ [0.01066372, 0.1696372, 0.8796372],
+ [0.01067092, 0.1697092, 0.8797092],
+ [0.01067688, 0.1697688, 0.8797688],
+ [0.01068304, 0.1698304, 0.8798304],
+ [0.01068936, 0.1698936, 0.8798936],
+ [0.01069756, 0.1699756, 0.8799756],
+ [0.01070428, 0.1700428, 0.8800428],
+ [0.01071124, 0.1701124, 0.8801124],
+ [0.0107184, 0.170184, 0.880184],
+ [0.0107276, 0.170276, 0.880276],
+ [0.0107352, 0.170352, 0.880352],
+ [0.01074296, 0.1704296, 0.8804296],
+ [0.01075092, 0.1705092, 0.8805092],
+ [0.01076116, 0.1706116, 0.8806116],
+ [0.0107696, 0.170696, 0.880696],
+ [0.01077824, 0.1707824, 0.8807824],
+ [0.01078708, 0.1708708, 0.8808708],
+ [0.0107984, 0.170984, 0.880984],
+ [0.01080772, 0.1710772, 0.8810772],
+ [0.0108172, 0.171172, 0.881172],
+ [0.0108294, 0.171294, 0.881294],
+ [0.0108394, 0.171394, 0.881394],
+ [0.0108496, 0.171496, 0.881496],
+ [0.01074856, 0.1704856, 0.8804856],
+ [0.01064964, 0.1694964, 0.8794964],
+ [0.01052896, 0.1682896, 0.8782896],
+ [0.01043476, 0.1673476, 0.8773476],
+ [0.0103426, 0.166426, 0.876426],
+ [0.01025252, 0.1655252, 0.8755252],
+ [0.0101428, 0.164428, 0.874428],
+ [0.01005732, 0.1635732, 0.8735732],
+ [0.0099738, 0.162738, 0.872738],
+ [0.00987228, 0.1617228, 0.8717228],
+ [0.009716, 0.16016, 0.87016],
+ [0.0096412, 0.159412, 0.869412],
+ [0.009568, 0.15868, 0.86868],
+ [0.00947924, 0.1577924, 0.8677924],
+ [0.0094104, 0.157104, 0.867104],
+ [0.00934348, 0.1564348, 0.8664348],
+ [0.0092624, 0.155624, 0.865624],
+ [0.00919968, 0.1549968, 0.8649968],
+ [0.0091388, 0.154388, 0.864388],
+ [0.00907968, 0.1537968, 0.8637968],
+ [0.0090224, 0.153224, 0.863224],
+ [0.00895336, 0.1525336, 0.8625336],
+ [0.00890008, 0.1520008, 0.8620008],
+ [0.0088486, 0.151486, 0.861486],
+ [0.00878664, 0.1508664, 0.8608664],
+ [0.00873904, 0.1503904, 0.8603904],
+ [0.00869312, 0.1499312, 0.8599312],
+ [0.00864888, 0.1494888, 0.8594888],
+ [0.00860632, 0.1490632, 0.8590632],
+ [0.00855544, 0.1485544, 0.8585544],
+ [0.00851652, 0.1481652, 0.8581652],
+ [0.00851652, 0.1481652, 0.8581652],
+ [0.00847924, 0.1477924, 0.8577924],
+ [0.00844356, 0.1474356, 0.8574356],
+ [0.00840112, 0.1470112, 0.8570112],
+ [0.00836888, 0.1466888, 0.8566888],
+ [0.0083382, 0.146382, 0.856382],
+ [0.008309, 0.14609, 0.85609],
+ [0.00827456, 0.1457456, 0.8557456],
+ [0.00824868, 0.1454868, 0.8554868],
+ [0.00822416, 0.1452416, 0.8552416],
+ [0.00819556, 0.1449556, 0.8549556],
+ [0.00817416, 0.1447416, 0.8547416],
+ [0.00815416, 0.1445416, 0.8545416],
+ [0.00813544, 0.1443544, 0.8543544],
+ [0.00811804, 0.1441804, 0.8541804],
+ [0.00809808, 0.1439808, 0.8539808],
+ [0.00808348, 0.1438348, 0.8538348],
+ [0.00807012, 0.1437012, 0.8537012],
+ [0.00805504, 0.1435504, 0.8535504],
+ [0.00804424, 0.1434424, 0.8534424],
+ [0.00803456, 0.1433456, 0.8533456],
+ [0.00802592, 0.1432592, 0.8532592],
+ [0.00801832, 0.1431832, 0.8531832],
+ [0.00801024, 0.1431024, 0.8531024],
+ [0.0080048, 0.143048, 0.853048],
+ [0.00800028, 0.1430028, 0.8530028],
+ [0.00799572, 0.1429572, 0.8529572],
+ [0.00799224, 0.1429224, 0.8529224],
+ [0.0079888, 0.142888, 0.852888],
+ [0.0079898, 0.142898, 0.852898],
+ [0.00799084, 0.1429084, 0.8529084],
+ [0.0079922, 0.142922, 0.852922],
+ [0.0079936, 0.142936, 0.852936],
+ [0.0079952, 0.142952, 0.852952],
+ [0.00799704, 0.1429704, 0.8529704],
+ [0.00799984, 0.1429984, 0.8529984],
+ [0.0080024, 0.143024, 0.853024],
+ [0.00800528, 0.1430528, 0.8530528],
+ [0.00800848, 0.1430848, 0.8530848],
+ [0.00801296, 0.1431296, 0.8531296],
+ [0.00801692, 0.1431692, 0.8531692],
+ [0.00802128, 0.1432128, 0.8532128],
+ [0.0080272, 0.143272, 0.853272],
+ [0.0080324, 0.143324, 0.853324],
+ [0.00803796, 0.1433796, 0.8533796],
+ [0.00804388, 0.1434388, 0.8534388],
+ [0.00805024, 0.1435024, 0.8535024],
+ [0.0080588, 0.143588, 0.853588],
+ [0.00806604, 0.1436604, 0.8536604],
+ [0.00807372, 0.1437372, 0.8537372],
+ [0.008084, 0.14384, 0.85384],
+ [0.00809264, 0.1439264, 0.8539264],
+ [0.00810176, 0.1440176, 0.8540176],
+ [0.00811136, 0.1441136, 0.8541136],
+ [0.008124, 0.14424, 0.85424],
+ [0.0081346, 0.144346, 0.854346],
+ [0.00814568, 0.1444568, 0.8544568],
+ [0.0081572, 0.144572, 0.854572],
+ [0.00817236, 0.1447236, 0.8547236],
+ [0.008185, 0.14485, 0.85485],
+ [0.00819816, 0.1449816, 0.8549816],
+ [0.0082118, 0.145118, 0.855118],
+ [0.008226, 0.14526, 0.85526],
+ [0.0082444, 0.145444, 0.855444],
+ [0.00826, 0.1456, 0.8556],
+ [0.00827552, 0.1457552, 0.8557552],
+ [0.00829188, 0.1459188, 0.8559188],
+ [0.00831308, 0.1461308, 0.8561308],
+ [0.00833064, 0.1463064, 0.8563064],
+ [0.00834872, 0.1464872, 0.8564872],
+ [0.00837212, 0.1467212, 0.8567212],
+ [0.00839148, 0.1469148, 0.8569148],
+ [0.00841136, 0.1471136, 0.8571136],
+ [0.00843184, 0.1473184, 0.8573184],
+ [0.00845288, 0.1475288, 0.8575288],
+ [0.00848, 0.1478, 0.8578],
+ [0.00850228, 0.1480228, 0.8580228],
+ [0.0085252, 0.148252, 0.858252],
+ [0.00855464, 0.1485464, 0.8585464],
+ [0.00857884, 0.1487884, 0.8587884],
+ [0.00860368, 0.1490368, 0.8590368],
+ [0.00862908, 0.1492908, 0.8592908],
+ [0.00866172, 0.1496172, 0.8596172],
+ [0.00868848, 0.1498848, 0.8598848],
+ [0.00871588, 0.1501588, 0.8601588],
+ [0.00874388, 0.1504388, 0.8604388],
+ [0.00877976, 0.1507976, 0.8607976],
+ [0.0088092, 0.151092, 0.861092],
+ [0.0088392, 0.151392, 0.861392],
+ [0.008829, 0.15129, 0.86129],
+ [0.008819, 0.15119, 0.86119],
+ [0.0088068, 0.151068, 0.861068],
+ [0.00879732, 0.1509732, 0.8609732],
+ [0.008788, 0.15088, 0.86088],
+ [0.00877668, 0.1507668, 0.8607668],
+ [0.00876784, 0.1506784, 0.8606784],
+ [0.0087592, 0.150592, 0.860592],
+ [0.0087508, 0.150508, 0.860508],
+ [0.00874256, 0.1504256, 0.8604256],
+ [0.00873256, 0.1503256, 0.8603256],
+ [0.0087248, 0.150248, 0.860248],
+ [0.0087172, 0.150172, 0.860172],
+ [0.008708, 0.15008, 0.86008],
+ [0.00870084, 0.1500084, 0.8600084],
+ [0.00869388, 0.1499388, 0.8599388],
+ [0.00868712, 0.1498712, 0.8598712],
+ [0.00867896, 0.1497896, 0.8597896],
+ [0.00867264, 0.1497264, 0.8597264],
+ [0.00866648, 0.1496648, 0.8596648],
+ [0.00866052, 0.1496052, 0.8596052],
+ [0.00865332, 0.1495332, 0.8595332],
+ [0.00864776, 0.1494776, 0.8594776],
+ [0.0086424, 0.149424, 0.859424],
+ [0.0086372, 0.149372, 0.859372],
+ [0.00863092, 0.1493092, 0.8593092],
+ [0.00862612, 0.1492612, 0.8592612],
+ [0.00862148, 0.1492148, 0.8592148],
+ [0.008617, 0.14917, 0.85917],
+ [0.00861272, 0.1491272, 0.8591272],
+ [0.0086076, 0.149076, 0.859076],
+ [0.00860368, 0.1490368, 0.8590368],
+ [0.00859988, 0.1489988, 0.8589988],
+ [0.00859628, 0.1489628, 0.8589628],
+ [0.008592, 0.14892, 0.85892],
+ [0.00858876, 0.1488876, 0.8588876],
+ [0.00858564, 0.1488564, 0.8588564],
+ [0.00858272, 0.1488272, 0.8588272],
+ [0.00857924, 0.1487924, 0.8587924],
+ [0.0085766, 0.148766, 0.858766],
+ [0.00857416, 0.1487416, 0.8587416],
+ [0.0085718, 0.148718, 0.858718],
+ [0.00856908, 0.1486908, 0.8586908],
+ [0.00856708, 0.1486708, 0.8586708],
+ [0.0085652, 0.148652, 0.858652],
+ [0.008563, 0.14863, 0.85863],
+ [0.0085614, 0.148614, 0.858614],
+ [0.00856, 0.1486, 0.8586],
+ [0.0085586, 0.148586, 0.858586],
+ [0.00855736, 0.1485736, 0.8585736],
+ [0.008556, 0.14856, 0.85856],
+ [0.008555, 0.14855, 0.85855],
+ [0.00855412, 0.1485412, 0.8585412],
+ [0.0085532, 0.148532, 0.858532],
+ [0.00855256, 0.1485256, 0.8585256],
+ [0.008552, 0.14852, 0.85852],
+ [0.00855156, 0.1485156, 0.8585156],
+ [0.00855108, 0.1485108, 0.8585108],
+ [0.00855072, 0.1485072, 0.8585072],
+];
diff --git a/esp/src/src-react/util/theme/colors/index.ts b/esp/src/src-react/util/theme/colors/index.ts
new file mode 100644
index 00000000000..d6089015886
--- /dev/null
+++ b/esp/src/src-react/util/theme/colors/index.ts
@@ -0,0 +1,4 @@
+export * from "./csswg";
+export * from "./geometry";
+export * from "./palette";
+export * from "./types";
\ No newline at end of file
diff --git a/esp/src/src-react/util/theme/colors/palette.ts b/esp/src/src-react/util/theme/colors/palette.ts
new file mode 100644
index 00000000000..9de26314a45
--- /dev/null
+++ b/esp/src/src-react/util/theme/colors/palette.ts
@@ -0,0 +1,205 @@
+/* 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";
+import { hueToSnappingPointsMap, hexToHue } from "./hueMap";
+// 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;
+
+const snappingPointsForKeyColor = (keyColor: string): number[] => {
+ const hue = hexToHue(keyColor);
+ const range = [
+ hueToSnappingPointsMap[hue][0] * 100,
+ hueToSnappingPointsMap[hue][1] * 100,
+ hueToSnappingPointsMap[hue][2] * 100,
+ ];
+ return range;
+};
+
+const pointsForKeyColor = (keyColor: string, range: number[], centerPoint: number): number[] => {
+ const hue = hexToHue(keyColor);
+ const center = hueToSnappingPointsMap[hue][1] * 100;
+ const linear = linearInterpolationThroughPoint(range[0], range[1], center, 16);
+ return linear;
+};
+
+function linearInterpolationThroughPoint(start: number, end: number, inBetween: number, numSamples: number) {
+ if (numSamples < 3) {
+ throw new Error("Number of samples must be at least 3.");
+ }
+
+ // Find the ratio of the inBetween point
+ const inBetweenRatio = (inBetween - start) / (end - start);
+
+ // Calculate the index of the inBetween point in the resulting array
+ const inBetweenIndex = Math.floor((numSamples - 1) * inBetweenRatio);
+
+ // Initialize the output array
+ const result = new Array(numSamples);
+
+ // Set start, inBetween and end points in the result array
+ result[0] = start;
+ result[inBetweenIndex] = inBetween;
+ result[numSamples - 1] = end;
+
+ // Calculate the step size for each segment
+ const stepBefore = (inBetween - start) / inBetweenIndex;
+ const stepAfter = (end - inBetween) / (numSamples - 1 - inBetweenIndex);
+
+ // Fill the array with interpolated values before the inBetween point
+ for (let i = 1; i < inBetweenIndex; i++) {
+ result[i] = start + i * stepBefore;
+ }
+
+ // Fill the array with interpolated values after the inBetween point
+ for (let i = inBetweenIndex + 1; i < numSamples - 1; i++) {
+ result[i] = inBetween + (i - inBetweenIndex) * stepAfter;
+ }
+
+ 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,
+ linearity = defaultLinearity,
+ keyColor: string,
+): Vec3[] {
+ if (curvePoints.length <= 2) {
+ return [];
+ }
+
+ const snappingPoints = snappingPointsForKeyColor(keyColor);
+ const paletteShades = [];
+ const range = [snappingPoints[0], snappingPoints[2]];
+ const logLightness = getLogSpace(Math.log10(0), Math.log10(100), nShades);
+ const linearLightness = pointsForKeyColor(keyColor, range, snappingPoints[1]);
+ let c = 0;
+
+ // obtain 2d path thru color space to grab points from
+ 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(
+ keyColor: string,
+ curve: CurvedHelixPath,
+ nShades = 16,
+ linearity = defaultLinearity,
+ curveDepth = 24,
+): Vec3[] {
+ const points = getPointsOnCurvePath(curve, Math.ceil((curveDepth * (1 + Math.abs(curve.torsion || 1))) / 2)).map(
+ (curvePoint: Vec3) => getPointOnHelix(curvePoint, curve.torsion, curve.torsionT0),
+ );
+ return paletteShadesFromCurvePoints(points, nShades, linearity, keyColor);
+}
+
+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 hexColorsFromPalette(
+ keyColor: string,
+ palette: Palette,
+ nShades = 16,
+ linearity = defaultLinearity,
+ curveDepth = 24,
+): string[] {
+ const curve = curvePathFromPalette(palette);
+ const shades = paletteShadesFromCurve(keyColor, curve, nShades, linearity, curveDepth);
+ return paletteShadesToHex(shades);
+}
diff --git a/esp/src/src-react/util/theme/colors/types.ts b/esp/src/src-react/util/theme/colors/types.ts
new file mode 100644
index 00000000000..c4be42c8b2f
--- /dev/null
+++ b/esp/src/src-react/util/theme/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/esp/src/src-react/util/theme/getBrandTokensFromPalette.ts b/esp/src/src-react/util/theme/getBrandTokensFromPalette.ts
new file mode 100644
index 00000000000..5e4fa88ff06
--- /dev/null
+++ b/esp/src/src-react/util/theme/getBrandTokensFromPalette.ts
@@ -0,0 +1,38 @@
+import { BrandVariants } from "@fluentui/react-theme";
+import { Palette, hexColorsFromPalette, hex_to_LCH } from "./colors/index";
+
+type Options = {
+ darkCp?: number;
+ lightCp?: number;
+ hueTorsion?: number;
+};
+
+/**
+ * A palette is represented as a continuous curve through LAB space, made of two quadratic bezier curves that start at
+ * 0L (black) and 100L (white) and meet at the LAB value of the provided key color.
+ *
+ * This function takes in a palette as input, which consists of:
+ * keyColor: The primary color in the LCH (Lightness Chroma Hue) color space
+ * darkCp, lightCp: The control point of the quadratic beizer curve towards black and white, respectively (between 0-1).
+ * Higher values move the control point toward the ends of the gamut causing chroma/saturation to
+ * diminish more slowly near the key color, and lower values move the control point toward the key
+ * color causing chroma/saturation to diminish more linearly.
+ * hueTorsion: Enables the palette to move through different hues by rotating the curve’s points in LAB space,
+ * creating a helical curve
+
+ * The function returns a set of brand tokens.
+ */
+export function getBrandTokensFromPalette(keyColor: string, options: Options = {}) {
+ const { darkCp = 2 / 3, lightCp = 1 / 3, hueTorsion = 0 } = options;
+ const brandPalette: Palette = {
+ keyColor: hex_to_LCH(keyColor),
+ darkCp,
+ lightCp,
+ hueTorsion,
+ };
+ const hexColors = hexColorsFromPalette(keyColor, brandPalette, 16, 1);
+ return hexColors.reduce((acc: Record, hexColor, h) => {
+ acc[`${(h + 1) * 10}`] = hexColor;
+ return acc;
+ }, {}) as BrandVariants;
+}
diff --git a/esp/src/src-react/util/theme/index.ts b/esp/src/src-react/util/theme/index.ts
new file mode 100644
index 00000000000..7c531d62cac
--- /dev/null
+++ b/esp/src/src-react/util/theme/index.ts
@@ -0,0 +1 @@
+export * from "./getBrandTokensFromPalette";
\ No newline at end of file
diff --git a/esp/src/src/nls/hpcc.ts b/esp/src/src/nls/hpcc.ts
index 5eb1fa02db9..1f23cc5bf6a 100644
--- a/esp/src/src/nls/hpcc.ts
+++ b/esp/src/src/nls/hpcc.ts
@@ -72,6 +72,7 @@ export = {
AutoRefreshIncrement: "Auto Refresh Increment",
AutoRefreshEvery: "Auto refresh every x minutes",
Back: "Back",
+ BackgroundColor: "Background Color",
BannerColor: "Banner Color",
BannerColorTooltip: "Change the background color of the top navigation",
BannerMessage: "Banner Message",
@@ -848,6 +849,7 @@ export = {
SetToolbarColor: "Set Toolbar Color",
SetUnprotected: "Set Unprotected",
SetValue: "Set Value",
+ Settings: "Settings",
Severity: "Severity",
ShareWorkunit: "Share Workunit URL",
Show: "Show",
@@ -934,7 +936,12 @@ export = {
TechPreview: "Tech Preview",
Terminators: "Terminators",
TestPages: "Test Pages",
+ TextColor: "Text Color",
TheReturnedResults: "The returned results",
+ Theme: "Theme",
+ Theme_PrimaryColor: "Primary Color",
+ Theme_HueTorsion: "Hue Torsion",
+ Theme_Vibrancy: "Vibrancy",
ThorNetworkAddress: "Thor Network Address",
ThorMasterAddress: "Thor Master Address",
ThorProcess: "Thor Process",
@@ -1027,6 +1034,7 @@ export = {
To: "To",
ToDate: "To Date",
Toenablegraphviews: "To enable graph views, please install the Graph View Control plugin",
+ ToolbarColor: "Toolbar Color",
Tooltip: "Tooltip",
TooManyFiles: "Too many files",
Top: "Top",