├── public ├── CNAME ├── favicon.png ├── logo192.png ├── logo512.png └── robots.txt ├── src ├── core │ ├── appService │ │ ├── dataflow.md │ │ ├── index.ts │ │ ├── dto.ts │ │ └── api.ts │ ├── frameworks │ │ ├── mantine.ts │ │ ├── index.ts │ │ ├── bootstrap5 │ │ │ ├── index.ts │ │ │ ├── generator.ts │ │ │ └── data.ts │ │ ├── tailwind3 │ │ │ ├── index.ts │ │ │ ├── generator.ts │ │ │ └── data.ts │ │ ├── vanillaCss.ts │ │ └── api.ts │ ├── domain │ │ ├── index.ts │ │ ├── constants.ts │ │ ├── palette.ts │ │ ├── api.ts │ │ ├── types.ts │ │ ├── math.ts │ │ ├── logic.ts │ │ └── transformations.ts │ ├── readme.md │ ├── play.ts │ ├── index.ts │ └── validators.ts ├── vite-env.d.ts ├── index.css ├── assets │ ├── images │ │ ├── sun.png │ │ ├── catwoman.png │ │ └── painter.jpg │ └── react.svg ├── theme.ts ├── hooks │ ├── useAppDispatch.ts │ └── useAppSelector.ts ├── components │ ├── Shade.tsx │ ├── MenuBottom.tsx │ ├── FrameworkList.tsx │ ├── Header.tsx │ ├── MenuBox.tsx │ ├── CodeContent.tsx │ ├── Palette.tsx │ ├── CopyCodeButton.tsx │ ├── ColorScaleRow.tsx │ ├── FrameworkItem.tsx │ ├── HueModSlider.tsx │ ├── SatModSlider.tsx │ ├── Main.tsx │ └── ModPicker.tsx ├── main.tsx ├── store │ ├── index.ts │ └── slices │ │ └── appSlice.ts └── App.tsx ├── README.md ├── tsconfig.node.json ├── vite.config.ts ├── .gitignore ├── postcss.config.js ├── .eslintrc.cjs ├── tests ├── palette.test.ts ├── logic.test.ts ├── validators.test.ts ├── math.test.ts └── transformations.test.ts ├── index.html ├── process.md ├── tsconfig.json └── package.json /public/CNAME: -------------------------------------------------------------------------------- 1 | palettolithic.com -------------------------------------------------------------------------------- /src/core/appService/dataflow.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import '@fontsource/frijole'; 2 | @import '@fontsource/schoolbell'; -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/core/frameworks/mantine.ts: -------------------------------------------------------------------------------- 1 | import { type IFramework, type ChromaticColorScale } from "./types"; 2 | -------------------------------------------------------------------------------- /src/assets/images/sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/src/assets/images/sun.png -------------------------------------------------------------------------------- /src/assets/images/catwoman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/src/assets/images/catwoman.png -------------------------------------------------------------------------------- /src/assets/images/painter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tombohub/palettolithic/HEAD/src/assets/images/painter.jpg -------------------------------------------------------------------------------- /src/core/domain/index.ts: -------------------------------------------------------------------------------- 1 | export { domainModule } from "./api"; 2 | export { type ColorScale, type PaletteShade } from "./types"; 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Palettolithic 2 | 3 | Color palette and code generator for Tailwind and Bootstrap. 4 | 5 | https://palettolithic.com 6 | -------------------------------------------------------------------------------- /src/core/frameworks/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | generateConfigurationCode, 3 | getChromaticPalette, 4 | getNeutralPalette, 5 | } from "./api"; 6 | -------------------------------------------------------------------------------- /src/core/frameworks/bootstrap5/index.ts: -------------------------------------------------------------------------------- 1 | export { generateConfigCode } from "./generator"; 2 | export { chromaticPalette, neutralPalette } from "./data"; 3 | -------------------------------------------------------------------------------- /src/core/readme.md: -------------------------------------------------------------------------------- 1 | This is the core of the application. 2 | 3 | It's designed to be standalone so it can be easily used with other ui frameworks or any other use interface 4 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@mantine/core"; 2 | 3 | export const theme = createTheme({ 4 | fontFamilyMonospace: "Menlo, Monaco, Consolas, monospace", 5 | }); 6 | -------------------------------------------------------------------------------- /src/hooks/useAppDispatch.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import { AppDispatch } from "@/store"; 3 | 4 | export const useAppDispatch = useDispatch.withTypes(); 5 | -------------------------------------------------------------------------------- /src/hooks/useAppSelector.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import type { RootState } from "@/store"; 3 | 4 | export const useAppSelector = useSelector.withTypes(); 5 | -------------------------------------------------------------------------------- /src/components/Shade.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | shadeHexValue: string; 3 | } 4 | 5 | export default function Shade(props: Props) { 6 | return
; 7 | } 8 | -------------------------------------------------------------------------------- /src/core/frameworks/tailwind3/index.ts: -------------------------------------------------------------------------------- 1 | export { chromaticPalette, neutralPalette } from "./data"; 2 | import { generateConfigCode } from "./generator"; 3 | 4 | export { generateConfigCode as generateCodeTailwind }; 5 | -------------------------------------------------------------------------------- /src/core/play.ts: -------------------------------------------------------------------------------- 1 | import { chromaticPalette } from "./frameworks/bootstrap5/data"; 2 | import { generateConfigurationCode } from "../core/frameworks"; 3 | 4 | const l = generateConfigurationCode("bootstrap5", chromaticPalette); 5 | 6 | console.log(l); 7 | -------------------------------------------------------------------------------- /src/core/appService/index.ts: -------------------------------------------------------------------------------- 1 | export { type ColorScale, type Framework } from "../domain/types"; 2 | export { 3 | type CreatePalleteInputDto, 4 | type CreatePaletteOutputDto, 5 | type InitialStateDto, 6 | } from "./dto"; 7 | export { createPalette, initializeState } from "./api"; 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createPalette, 3 | initializeState, 4 | type CreatePalleteInputDto, 5 | type CreatePaletteOutputDto, 6 | type InitialStateDto, 7 | type Framework, 8 | type ColorScale, 9 | } from "./appService"; 10 | export { frameworksList } from "./domain/constants"; 11 | -------------------------------------------------------------------------------- /src/components/MenuBottom.tsx: -------------------------------------------------------------------------------- 1 | import { AiFillGithub } from "react-icons/ai"; 2 | 3 | export default function MenuBottom() { 4 | return ( 5 | <> 6 |
7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import "./assets/css/main.css"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/core/domain/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of supported frameworks 3 | */ 4 | export const frameworksList = [ 5 | "tailwind", 6 | "bootstrap5", 7 | // "css", 8 | // "mantine", 9 | ] as const; 10 | 11 | /** 12 | * Values input can have for saturation and hue modification 13 | */ 14 | export const modFactorRange = { min: -1, max: 1 } as const; 15 | 16 | export const saturationRange = { min: 0, max: 1 } as const; 17 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import appReducer from "./slices/appSlice"; 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | app: appReducer, 7 | }, 8 | }); 9 | 10 | // Infer the `RootState` and `AppDispatch` types from the store itself 11 | export type RootState = ReturnType; 12 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 13 | export type AppDispatch = typeof store.dispatch; 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs', 'node_modules'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | '@typescript-eslint/no-unused-vars': 'warn' 18 | 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/components/FrameworkList.tsx: -------------------------------------------------------------------------------- 1 | // components 2 | import FrameworkItem from "./FrameworkItem"; 3 | import { useAppSelector } from "@/hooks/useAppSelector"; 4 | 5 | /** 6 | * WHAT: Menu list of frameworks to choose from. 7 | * The code will display based on active framework 8 | */ 9 | function FrameworkList() { 10 | const frameworks = useAppSelector(state => state.app.frameworks); 11 | return ( 12 |
    13 | {frameworks.map(framework => ( 14 | 15 | ))} 16 |
17 | ); 18 | } 19 | 20 | export default FrameworkList; 21 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Title } from "@mantine/core"; 2 | 3 | /** 4 | * WHAT: holds the title of the website 5 | */ 6 | function Header() { 7 | return ( 8 | <> 9 | 15 | Palettolithic 16 | 17 | 21 | So easy caveman can do it... 22 | 23 | 24 | ); 25 | } 26 | 27 | export default Header; 28 | -------------------------------------------------------------------------------- /src/components/MenuBox.tsx: -------------------------------------------------------------------------------- 1 | import FrameworkList from "./FrameworkList"; 2 | import HueModSlider from "./HueModSlider"; 3 | import MenuBottom from "./MenuBottom"; 4 | import ModPicker from "./ModPicker"; 5 | import SatModSlider from "./SatModSlider"; 6 | 7 | /** 8 | * WHAT: menu box to hold color picker and frameworks menu items 9 | */ 10 | function MenuBox() { 11 | return ( 12 | <> 13 | {/* */} 14 | 15 | {/* NOTE: don't use until figure out how to modifiy hues */} 16 | {/* */} 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default MenuBox; 26 | -------------------------------------------------------------------------------- /src/core/frameworks/vanillaCss.ts: -------------------------------------------------------------------------------- 1 | import { type IFramework, type ChromaticColorScale } from "./types"; 2 | 3 | /** 4 | * Generates CSS color variables template 5 | * @param {ChromaticColorScale[]} palette palette object generated by user choosing color 6 | */ 7 | export function generateCssVariables(palette: ChromaticColorScale[]): string { 8 | let variables = ""; 9 | 10 | palette.forEach(({ colorName, shades }) => { 11 | shades.forEach(({ weight, hexCode }) => { 12 | variables += `--${colorName}-${weight}: ${hexCode};\n`; 13 | }); 14 | variables += "\n"; 15 | }); 16 | 17 | return variables.trim(); // Remove the trailing newline for clean output 18 | } 19 | -------------------------------------------------------------------------------- /tests/palette.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { hslFromHex } from "../src/core/domain/palette"; 3 | import { type HSL } from "../src/core/domain/types"; 4 | 5 | describe("testing hslFromHex function", () => { 6 | it("should convert hex code to correct hsl", () => { 7 | const hexCode = "#2a4675"; 8 | const result = hslFromHex(hexCode); 9 | const expected: HSL = { 10 | hue: 217.6, 11 | saturation: 0.47169811320754707, 12 | luminosity: 0.31176470588235294, 13 | }; 14 | 15 | expect(result).toEqual(expected); 16 | }); 17 | it("shoudl throw Error on invalid hex", () => { 18 | const hex = "#zzz"; 19 | 20 | expect(() => hslFromHex(hex)).toThrowError(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/core/frameworks/bootstrap5/generator.ts: -------------------------------------------------------------------------------- 1 | import { type PaletteShade, type ColorScale } from "../../domain/types"; 2 | 3 | function scssVariable( 4 | colorName: string, 5 | weight: number, 6 | hexCode: string 7 | ): string { 8 | return `$${colorName}-${weight}: ${hexCode};`; 9 | } 10 | 11 | function scssShadesList(shades: PaletteShade[], colorName: string): string { 12 | const scale: string[] = shades.map( 13 | x => `${scssVariable(colorName, x.weight, x.hexCode)}` 14 | ); 15 | return scale.join("\n"); 16 | } 17 | 18 | export function generateConfigCode(palette: ColorScale[]): string { 19 | const scssVariableList = palette.map(x => 20 | scssShadesList(x.shades, x.colorName) 21 | ); 22 | return scssVariableList.join("\n\n"); 23 | } 24 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "@mantine/core/styles.css"; 2 | import "@mantine/code-highlight/styles.css"; 3 | import Main from "./components/Main"; 4 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 5 | import { store } from "./store"; 6 | import { Provider as ReduxProvider } from "react-redux"; 7 | import { MantineProvider } from "@mantine/core"; 8 | import { theme } from "./theme"; 9 | 10 | function App() { 11 | return ( 12 | 13 | 14 | 15 | 16 | } /> 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/components/CodeContent.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "@/hooks/useAppSelector"; 2 | import CopyCodeButton from "./CopyCodeButton"; 3 | import { CodeHighlight } from "@mantine/code-highlight"; 4 | import { Box } from "@mantine/core"; 5 | 6 | /** 7 | * Actual code of chosen framework. All the frameworks will render here because we 8 | * want to use syntax highlighter in one place. 9 | * 10 | */ 11 | function CodeContent() { 12 | const configurationCode = useAppSelector( 13 | state => state.app.configurationCode 14 | ); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export default CodeContent; 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | Palettolithic 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /process.md: -------------------------------------------------------------------------------- 1 | ### Framework: 2 | 3 | framework has: 4 | 5 | - list of color names 6 | - each color has hue dependant on framework. Tailwind's lime is hue 85, which is not correct according to X11 web standard 7 | - number of shades for each color 8 | - shade weight number, usually 100, 200 etc 9 | - luminosity for each shade 10 | 11 | framework needs to calculate: 12 | 13 | - hue range for each color: take the central hue and divide the hue circle equally among framework's color list 14 | - shade for each shade weight 15 | 16 | Backend workflow: 17 | 18 | 1. Take initial color input from user 19 | 2. Take initial color's hue and saturation 20 | 3. Determine other colors hues based on initial color hue. 21 | 4. Determine other colors saturation base on initial color saturation. 22 | 5. Luminosity take from framework's original color palette. 23 | 6. Create shades for each color taking HSL values. 24 | 7. Generate color variables code 25 | -------------------------------------------------------------------------------- /src/components/Palette.tsx: -------------------------------------------------------------------------------- 1 | import ColorScaleRow from "./ColorScaleRow"; 2 | import { useAppSelector } from "@/hooks/useAppSelector"; 3 | import { Stack } from "@mantine/core"; 4 | 5 | /** 6 | * Hold the complete Palette. Which consists of Colors, inside Colors are Shades 7 | */ 8 | function Palette() { 9 | const palette = useAppSelector(state => state.app.currentPalette); 10 | 11 | // render the list of Color components based on colors.map and 12 | // pass the shades as props to the Color component, which it will use it to render 13 | // list of Shade component 14 | console.dir(palette); 15 | return ( 16 | 17 | {palette.map(colorScale => ( 18 | 23 | ))} 24 | 25 | ); 26 | } 27 | 28 | export default Palette; 29 | -------------------------------------------------------------------------------- /src/core/domain/palette.ts: -------------------------------------------------------------------------------- 1 | import chroma from "chroma-js"; 2 | import { type HSL } from "./types"; 3 | 4 | /** 5 | * Convert hex code to HSL data 6 | * 7 | * @param {string} hexCode - hex code. 8 | * @returns {number} object containg HSL values 9 | */ 10 | export function hslFromHex(hexCode: string): HSL { 11 | const [hue, sat, lum] = chroma(hexCode).hsl(); 12 | return { 13 | hue: hue, 14 | saturation: sat, 15 | luminosity: lum, 16 | }; 17 | } 18 | 19 | /** 20 | * Converts an HSL color value to a hexadecimal string. 21 | * 22 | * @param hsl - An object representing an HSL color. It should have properties for 'hue', 'saturation', and 'luminosity'. 23 | * @returns A string representing the hexadecimal color value. 24 | */ 25 | export function hexFromHsl(hsl: HSL): string { 26 | return chroma.hsl(hsl.hue, hsl.saturation, hsl.luminosity).hex(); 27 | } 28 | 29 | /* ------------------------------ Main Function ----------------------------- */ 30 | -------------------------------------------------------------------------------- /src/components/CopyCodeButton.tsx: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | gtag: ( 4 | type: string, 5 | event: string, 6 | options?: Record 7 | ) => void; 8 | } 9 | } 10 | 11 | import { Button } from "@mantine/core"; 12 | import { useAppSelector } from "@/hooks/useAppSelector"; 13 | import { useClipboard } from "@mantine/hooks"; 14 | 15 | export default function CopyCodeButton() { 16 | const clipboard = useClipboard(); 17 | const configurationCode = useAppSelector( 18 | state => state.app.configurationCode 19 | ); 20 | 21 | function handleClick() { 22 | clipboard.copy(configurationCode); 23 | // google analytics event no copy button click 24 | window.gtag("event", "click-copy-button"); 25 | } 26 | return ( 27 | <> 28 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ColorScaleRow.tsx: -------------------------------------------------------------------------------- 1 | import Shade from "./Shade"; 2 | import { Box, SimpleGrid } from "@mantine/core"; 3 | import { type ColorScale } from "@/core"; 4 | 5 | interface Props { 6 | /** 7 | * hex values of each shade 8 | */ 9 | shades: ColorScale["shades"]; 10 | 11 | colorName: string; 12 | } 13 | 14 | /** 15 | * Hold Shades of single Color. It lists all the Shades of the Color passed in props from 16 | * Pallete component. 17 | * @param {object} props passed from App->Palette. Single color 18 | */ 19 | export default function ColorScaleRow(props: Props) { 20 | return ( 21 | // grid is shades.count + 1 for shades + color name 22 | 23 | 24 | {props.colorName.toUpperCase()}: 25 | 26 | {props.shades.map(shade => ( 27 | 28 | ))} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/FrameworkItem.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch } from "@/hooks/useAppDispatch"; 2 | import { useAppSelector } from "@/hooks/useAppSelector"; 3 | import { appActions } from "@/store/slices/appSlice"; 4 | import { type Framework } from "@/core"; 5 | import { NavLink } from "@mantine/core"; 6 | 7 | interface Props { 8 | framework: Framework; 9 | } 10 | 11 | /** 12 | * WHAT: Menu Item in framework menu. Parent is FrameworkList 13 | * WHY: There's more than one framework user can chose so it deserves component 14 | */ 15 | function FrameworkItem(props: Props) { 16 | const dispatch = useAppDispatch(); 17 | const activeFramework = useAppSelector(state => state.app.activeFramework); 18 | 19 | const isActive = activeFramework === props.framework; 20 | 21 | function handleClick() { 22 | dispatch(appActions.setActiveFramework(props.framework)); 23 | } 24 | return ( 25 | 26 | ); 27 | } 28 | 29 | export default FrameworkItem; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | /* Linting */ 21 | "strict": true, 22 | "noImplicitAny": true, 23 | "strictFunctionTypes": true, 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noFallthroughCasesInSwitch": true, 27 | "baseUrl": ".", 28 | "paths": { 29 | "@/*": [ 30 | "./src/*" 31 | ] 32 | } 33 | }, 34 | "include": [ 35 | "src", 36 | "tests" 37 | ], 38 | "references": [ 39 | { 40 | "path": "./tsconfig.node.json" 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /src/core/frameworks/tailwind3/generator.ts: -------------------------------------------------------------------------------- 1 | import { type PaletteShade, type ColorScale } from "../../domain/types"; 2 | 3 | function generateShadeValue(shade: PaletteShade): string { 4 | return `${shade.weight}: '${shade.hexCode}',\n`; 5 | } 6 | 7 | function generateShadesObject(shades: PaletteShade[]): string { 8 | const openingBrace = "{\n"; 9 | const closingBrace = "},\n"; 10 | const indent = " "; 11 | const shadeValuesArray = shades.map(x => indent + generateShadeValue(x)); 12 | const shadeValuesString = shadeValuesArray.join(""); 13 | return openingBrace + shadeValuesString + closingBrace; 14 | } 15 | 16 | function generateColorObject(color: ColorScale): string { 17 | const colorKey = `'${color.colorName}'`; 18 | const shadesObject = generateShadesObject(color.shades); 19 | return `${colorKey}: ${shadesObject}`; 20 | } 21 | 22 | export function generateConfigCode(palette: ColorScale[]): string { 23 | const colorObjectsArray = palette.map(x => generateColorObject(x)); 24 | const colorObjectsString = colorObjectsArray.join(""); 25 | return colorObjectsString; 26 | } 27 | -------------------------------------------------------------------------------- /src/core/domain/api.ts: -------------------------------------------------------------------------------- 1 | import { type ColorScale, type ModFactor } from "./types"; 2 | import { 3 | WithAdjancentHues, 4 | addAdjancentHues, 5 | addHue, 6 | addHueRanges, 7 | addModifiedHex, 8 | filterByWeight, 9 | flatten, 10 | getDistinctWeights, 11 | sortByHue, 12 | transformToColorScale, 13 | } from "./transformations"; 14 | 15 | export const domainModule = { 16 | modifyPallete: (palette: ColorScale[], modFactor: ModFactor) => { 17 | const flattened = flatten(palette); 18 | const weights = getDistinctWeights(flattened); 19 | 20 | const withAdjancentHues: WithAdjancentHues[] = weights.flatMap(weight => { 21 | const filteredByWeight = filterByWeight(flattened, weight); 22 | const addedHues = addHue(filteredByWeight); 23 | const sortedByHue = sortByHue(addedHues); 24 | const addedAdjancentHues = addAdjancentHues(sortedByHue); 25 | 26 | console.dir( 27 | addedHues.filter(x => x.weight === 600), 28 | { depth: null } 29 | ); 30 | 31 | return addedAdjancentHues; 32 | }); 33 | 34 | const withHueRanges = addHueRanges(withAdjancentHues); 35 | const withModifiedHex = addModifiedHex(withHueRanges, modFactor); 36 | const newPalette = transformToColorScale(withModifiedHex); 37 | return newPalette; 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/core/validators.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates hex color code against 3 or 6 characters. No alpha. 3 | * @param hexCode hex color code 4 | * @returns true if hex code is valid 5 | */ 6 | export function validateHexColorValue(hexCode: string): boolean { 7 | const hexPattern = /^#?([0-9A-F]{3}|[0-9A-F]{6})$/i; 8 | return hexPattern.test(hexCode); 9 | } 10 | 11 | /** 12 | * Validates the luminosity value. 13 | * @param luminosity - The luminosity value to validate. 14 | * @returns True if the luminosity value is between 0 and 1 (inclusive), false otherwise. 15 | */ 16 | export function validateLuminosity(luminosity: number) { 17 | return luminosity >= 0 && luminosity <= 1; 18 | } 19 | 20 | /** 21 | * Validates the saturation value. 22 | * @param saturation - The saturation value to validate. 23 | * @returns True if the saturation value is between 0 and 1 (inclusive), false otherwise. 24 | */ 25 | export function validateSaturation(saturation: number) { 26 | return saturation >= 0 && saturation <= 1; 27 | } 28 | 29 | /** 30 | * Validates whether a given hue is within the valid range for HSL/HSV color models. 31 | * 32 | * @param {number} hue - The hue value to validate. 33 | * @returns {boolean} - Returns `true` if the hue is within the range [0, 360], otherwise `false`. 34 | */ 35 | export function validateHue(hue: number) { 36 | return hue >= 0 && hue <= 360; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/HueModSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Slider } from "@mantine/core"; 2 | import { useDebouncedCallback } from "@mantine/hooks"; 3 | import { useAppSelector } from "@/hooks/useAppSelector"; 4 | import { useAppDispatch } from "@/hooks/useAppDispatch"; 5 | import { appActions } from "@/store/slices/appSlice"; 6 | import { useState } from "react"; 7 | 8 | /** 9 | * Set the hue modification factor using the slider input 10 | */ 11 | export default function HueModSlider() { 12 | const dispatch = useAppDispatch(); 13 | const hueMod = useAppSelector(state => state.app.hueMod); 14 | const [value, setValue] = useState(hueMod); 15 | 16 | const debouncedHueChange = useDebouncedCallback((value: number) => { 17 | dispatch(appActions.setHueMod(value)); 18 | }, 300); 19 | 20 | function handleChange(value: number) { 21 | setValue(value); 22 | debouncedHueChange(value); 23 | } 24 | 25 | return ( 26 | <> 27 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SatModSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Slider } from "@mantine/core"; 2 | import { useDebouncedCallback } from "@mantine/hooks"; 3 | import { useAppSelector } from "@/hooks/useAppSelector"; 4 | import { useAppDispatch } from "@/hooks/useAppDispatch"; 5 | import { appActions } from "@/store/slices/appSlice"; 6 | import { useState } from "react"; 7 | 8 | /** 9 | * Set the saturation modification factor using the slider input 10 | */ 11 | export default function SatModSlider() { 12 | const dispatch = useAppDispatch(); 13 | const satMod = useAppSelector(state => state.app.saturationMod); 14 | const [value, setValue] = useState(satMod); 15 | 16 | const debouncedHueChange = useDebouncedCallback((value: number) => { 17 | dispatch(appActions.setSatMod(value)); 18 | }, 300); 19 | 20 | function handleChange(value: number) { 21 | setValue(value); 22 | debouncedHueChange(value); 23 | } 24 | 25 | return ( 26 | <> 27 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | // components 2 | import Palette from "./Palette"; 3 | import MenuBox from "./MenuBox"; 4 | import Header from "./Header"; 5 | import CodeContent from "./CodeContent.js"; 6 | import { Box } from "@mantine/core"; 7 | 8 | /** 9 | * Main component that displays the first page with form and palette 10 | */ 11 | function Main() { 12 | return ( 13 | <> 14 | ({ 16 | height: "100vh", 17 | display: "grid", 18 | gridTemplateColumns: "repeat(12, minmax(0, 1fr))", 19 | gridTemplateRows: "repeat(8, minmax(0, 1fr))", 20 | fontFamily: theme.fontFamilyMonospace, 21 | })} 22 | > 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ({ 37 | backgroundColor: theme.colors.dark, 38 | gridColumn: "span 2", 39 | gridRow: "span 7", 40 | overflow: "auto", 41 | })} 42 | > 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export default Main; 51 | -------------------------------------------------------------------------------- /src/core/domain/types.ts: -------------------------------------------------------------------------------- 1 | import { frameworksList } from "./constants"; 2 | 3 | /** 4 | * Individual shade in color palette associated with color name 5 | */ 6 | export type PaletteShade = { 7 | /** 8 | * hex code of the specific shade 9 | */ 10 | hexCode: string; 11 | 12 | /** 13 | * shade weight as numbered in original palette. 14 | * Lower number means lighter shade 15 | */ 16 | weight: number; 17 | }; 18 | 19 | /** 20 | * Color scale 21 | */ 22 | export type ColorScale = { 23 | /** 24 | * name of the color 25 | */ 26 | colorName: string; 27 | 28 | /** 29 | * order in which color appears in original palette 30 | */ 31 | order: number; 32 | 33 | /** 34 | * list of hex color codes which represent shades for the 35 | * corresponding color 36 | */ 37 | shades: PaletteShade[]; 38 | }; 39 | 40 | /** 41 | * Represents supported framework 42 | */ 43 | export type Framework = (typeof frameworksList)[number]; 44 | 45 | /** 46 | * HSL attributes of color 47 | */ 48 | export type HSL = { 49 | hue: number; 50 | saturation: number; 51 | luminosity: number; 52 | }; 53 | 54 | /** 55 | * Represents boundaries for the range of value 56 | * 57 | */ 58 | export type Range = { 59 | /** 60 | * lower boundary 61 | */ 62 | min: number; 63 | 64 | /** 65 | * upper boundary 66 | */ 67 | max: number; 68 | }; 69 | 70 | /** 71 | * Modification factor for the color 72 | */ 73 | export type ModFactor = { 74 | /** 75 | * mod factor for hue 76 | */ 77 | hueMod: number; 78 | 79 | /** 80 | * mod factor for saturation 81 | */ 82 | satMod: number; 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/ModPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useElementSize, useMove } from "@mantine/hooks"; 2 | import { Box, Center, rem } from "@mantine/core"; 3 | import { useState } from "react"; 4 | import Draggable from "react-draggable"; 5 | 6 | /** 7 | * Similar to color picker. Pick modification factors for hue and saturation 8 | * with single picker 9 | */ 10 | export default function ModPicker() { 11 | // const { ref, width, height } = useElementSize(); 12 | const [value, setValue] = useState({ x: 0.2, y: 0.6 }); 13 | const { ref, active } = useMove(setValue); 14 | 15 | return ( 16 | <> 17 |
18 | 30 | 31 | 39 | 47 | 48 |
49 | w: {value.x} h: {value.y} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/core/appService/dto.ts: -------------------------------------------------------------------------------- 1 | import { type Framework } from "../domain/types"; 2 | import { type ColorScale } from "../domain/types"; 3 | import { frameworksList } from "../domain/constants"; 4 | 5 | /** 6 | * Input for create palette service 7 | */ 8 | type CreatePalleteInputDto = { 9 | /** 10 | * Measure to use for saturaturation modification 11 | */ 12 | saturationMod: number; 13 | 14 | /** 15 | * Measure to use for hue modification 16 | */ 17 | hueMod: number; 18 | 19 | /** 20 | * framework user selects 21 | */ 22 | framework: Framework; 23 | }; 24 | 25 | /** 26 | * Output of create palette service 27 | */ 28 | type CreatePaletteOutputDto = { 29 | /** 30 | * code generated for selected framework 31 | */ 32 | code: string; 33 | 34 | /** 35 | * palette data generated for selected framework 36 | */ 37 | palette: ColorScale[]; 38 | }; 39 | 40 | /** 41 | * Initial data for the UI 42 | */ 43 | type InitialStateDto = { 44 | /** 45 | * list of supported frameworks 46 | */ 47 | frameworksList: typeof frameworksList; 48 | 49 | /** 50 | * initial saturation mod 51 | */ 52 | saturationMod: number; 53 | 54 | /** 55 | * initial hue mod 56 | */ 57 | hueMod: number; 58 | 59 | /** 60 | * initial framework's configuration code 61 | */ 62 | code: string; 63 | 64 | /** 65 | * initial framework's palette data 66 | */ 67 | paletteData: ColorScale[]; 68 | 69 | /** 70 | * framework owner of the configuration code and pallete data 71 | */ 72 | framework: Framework; 73 | }; 74 | 75 | export { 76 | type CreatePalleteInputDto, 77 | type CreatePaletteOutputDto, 78 | type InitialStateDto, 79 | }; 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite", 3 | "private": false, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "test": "vitest", 12 | "deploy": "gh-pages -d dist" 13 | }, 14 | "dependencies": { 15 | "@fontsource/frijole": "^5.0.8", 16 | "@fontsource/schoolbell": "^5.0.8", 17 | "@mantine/code-highlight": "^7.9.0", 18 | "@mantine/core": "^7.9.0", 19 | "@mantine/hooks": "^7.9.0", 20 | "@reduxjs/toolkit": "^2.2.3", 21 | "chroma-js": "^2.4.2", 22 | "react": "^18.2.0", 23 | "react-colorful": "^5.6.1", 24 | "react-dom": "^18.2.0", 25 | "react-draggable": "^4.4.6", 26 | "react-redux": "^9.1.2", 27 | "react-router-dom": "^6.22.2", 28 | "react-syntax-highlighter": "^15.5.0" 29 | }, 30 | "devDependencies": { 31 | "@types/chroma-js": "^2.4.4", 32 | "@types/lodash": "^4.17.0", 33 | "@types/react": "^18.2.56", 34 | "@types/react-copy-to-clipboard": "^5.0.7", 35 | "@types/react-dom": "^18.2.19", 36 | "@types/react-icons": "^3.0.0", 37 | "@types/react-router-dom": "^5.3.3", 38 | "@types/react-syntax-highlighter": "^15.5.11", 39 | "@typescript-eslint/eslint-plugin": "^7.0.2", 40 | "@typescript-eslint/parser": "^7.0.2", 41 | "@vitejs/plugin-react": "^4.2.1", 42 | "autoprefixer": "^10.4.18", 43 | "eslint": "^8.56.0", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "eslint-plugin-react-refresh": "^0.4.5", 46 | "gh-pages": "^6.1.1", 47 | "postcss": "^8.4.38", 48 | "postcss-preset-mantine": "^1.15.0", 49 | "postcss-simple-vars": "^7.0.1", 50 | "tsx": "^4.7.1", 51 | "typescript": "^5.2.2", 52 | "vite": "^5.2.11", 53 | "vitest": "^1.6.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/core/frameworks/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateCodeTailwind, 3 | chromaticPalette, 4 | neutralPalette, 5 | } from "./tailwind3"; 6 | import { 7 | generateConfigCode as generateCodeBootstrap5, 8 | chromaticPalette as chromaticPaletteBootstrap5, 9 | neutralPalette as neutralPaletteBootstrap5, 10 | } from "./bootstrap5"; 11 | import { type Framework, type ColorScale } from "../appService"; 12 | 13 | function generateConfigurationCode( 14 | framework: Framework, 15 | palette: ColorScale[] 16 | ): string { 17 | switch (framework) { 18 | case "bootstrap5": 19 | return generateCodeBootstrap5(palette); 20 | case "tailwind": 21 | return generateCodeTailwind(palette); 22 | case "css": 23 | throw new Error("not implemented"); 24 | case "mantine": 25 | throw new Error("not implemented"); 26 | default: 27 | throw new Error(`framework ${framework} is not implemented`); 28 | } 29 | } 30 | 31 | function getChromaticPalette(framework: Framework) { 32 | switch (framework) { 33 | case "bootstrap5": 34 | return chromaticPaletteBootstrap5; 35 | case "tailwind": 36 | return chromaticPalette; 37 | case "css": 38 | throw new Error("not implemented"); 39 | case "mantine": 40 | throw new Error("not implemented"); 41 | default: 42 | throw new Error(`framework ${framework} is not implemented`); 43 | } 44 | } 45 | 46 | export function getNeutralPalette(framework: Framework) { 47 | switch (framework) { 48 | case "bootstrap5": 49 | return neutralPaletteBootstrap5; 50 | case "tailwind": 51 | return neutralPalette; 52 | case "css": 53 | throw new Error("not implemented"); 54 | case "mantine": 55 | throw new Error("not implemented"); 56 | default: 57 | throw new Error(`framework ${framework} is not implemented`); 58 | } 59 | } 60 | 61 | export { getChromaticPalette, generateConfigurationCode }; 62 | -------------------------------------------------------------------------------- /src/core/appService/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CreatePalleteInputDto, 3 | type CreatePaletteOutputDto, 4 | type InitialStateDto, 5 | } from "./dto"; 6 | import { type Framework, type ModFactor } from "../domain/types"; 7 | import { frameworksList } from "../domain/constants"; 8 | import { domainModule } from "../domain"; 9 | import { 10 | generateConfigurationCode, 11 | getChromaticPalette, 12 | getNeutralPalette, 13 | } from "../frameworks"; 14 | 15 | /** 16 | * Generate palette from user inputs 17 | * @param input user selected inputs 18 | * @returns generated palette 19 | */ 20 | export function createPalette( 21 | input: CreatePalleteInputDto 22 | ): CreatePaletteOutputDto { 23 | // collect framework setup data 24 | const chromaticPalette = getChromaticPalette(input.framework); 25 | const neutralPalette = getNeutralPalette(input.framework); 26 | 27 | const modFactor: ModFactor = { 28 | hueMod: input.hueMod, 29 | satMod: input.saturationMod, 30 | }; 31 | const modifiedPalette = domainModule.modifyPallete( 32 | chromaticPalette, 33 | modFactor 34 | ); 35 | 36 | const newPalette: CreatePaletteOutputDto["palette"] = [ 37 | ...modifiedPalette, 38 | ...neutralPalette, 39 | ]; 40 | 41 | const code = generateConfigurationCode(input.framework, newPalette); 42 | 43 | const output: CreatePaletteOutputDto = { code, palette: newPalette }; 44 | return output; 45 | } 46 | 47 | /** 48 | * Initialize data and state for the first time load 49 | * @returns Initial data and state for the app 50 | */ 51 | export function initializeState(): InitialStateDto { 52 | const initialFramework: Framework = "tailwind"; 53 | const initialSaturationMod = 0; 54 | const initialHueMod = 0; 55 | 56 | // simulate user pick 57 | const inputDto: CreatePalleteInputDto = { 58 | framework: initialFramework, 59 | saturationMod: initialSaturationMod, 60 | hueMod: initialHueMod, 61 | }; 62 | const pallete = createPalette(inputDto); 63 | 64 | const initialDto: InitialStateDto = { 65 | frameworksList: [...frameworksList], 66 | saturationMod: initialSaturationMod, 67 | hueMod: initialHueMod, 68 | code: pallete.code, 69 | paletteData: pallete.palette, 70 | framework: initialFramework, 71 | }; 72 | 73 | return initialDto; 74 | } 75 | -------------------------------------------------------------------------------- /src/store/slices/appSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { PayloadAction } from "@reduxjs/toolkit"; 3 | import { 4 | createPalette, 5 | initializeState, 6 | type CreatePalleteInputDto, 7 | type CreatePaletteOutputDto, 8 | type InitialStateDto, 9 | type Framework, 10 | frameworksList, 11 | } from "@/core"; 12 | 13 | interface SliceState { 14 | /** 15 | * saturation mod 16 | */ 17 | saturationMod: number; 18 | 19 | /** 20 | * hue mod 21 | */ 22 | hueMod: number; 23 | 24 | /** 25 | * current color palette generated from picked hex value 26 | */ 27 | currentPalette: CreatePaletteOutputDto["palette"]; 28 | 29 | /** 30 | * available frameworks choices 31 | */ 32 | frameworks: typeof frameworksList; 33 | 34 | /** 35 | * currently selected framework by user 36 | */ 37 | activeFramework: InitialStateDto["framework"]; 38 | 39 | /** 40 | * generated configuration code for the framework 41 | */ 42 | configurationCode: string; 43 | } 44 | 45 | const initialState: SliceState = { 46 | saturationMod: initializeState().saturationMod, 47 | hueMod: initializeState().hueMod, 48 | currentPalette: initializeState().paletteData, 49 | frameworks: initializeState().frameworksList, 50 | activeFramework: initializeState().framework, 51 | configurationCode: initializeState().code, 52 | }; 53 | 54 | const appSlice = createSlice({ 55 | name: "app", 56 | initialState, 57 | reducers: { 58 | setActiveFramework: (state, action: PayloadAction) => { 59 | state.activeFramework = action.payload; 60 | state.configurationCode = newPaletteAndCode(state).code; 61 | state.currentPalette = newPaletteAndCode(state).palette.sort( 62 | (a, b) => a.order - b.order 63 | ); 64 | }, 65 | setHueMod: (state, action: PayloadAction) => { 66 | state.hueMod = action.payload; 67 | state.configurationCode = newPaletteAndCode(state).code; 68 | state.currentPalette = newPaletteAndCode(state).palette.sort( 69 | (a, b) => a.order - b.order 70 | ); 71 | }, 72 | setSatMod: (state, action: PayloadAction) => { 73 | state.saturationMod = action.payload; 74 | state.configurationCode = newPaletteAndCode(state).code; 75 | state.currentPalette = newPaletteAndCode(state).palette.sort( 76 | (a, b) => a.order - b.order 77 | ); 78 | }, 79 | }, 80 | }); 81 | 82 | /** 83 | * Call to generate framework configuration code based on current slice state. 84 | * @param state current slice state 85 | * @returns framework configuration code based on current state 86 | */ 87 | function newPaletteAndCode(state: SliceState) { 88 | const inputDto: CreatePalleteInputDto = { 89 | saturationMod: state.saturationMod, 90 | hueMod: state.hueMod, 91 | framework: state.activeFramework, 92 | }; 93 | const palette = createPalette(inputDto).palette; 94 | const code = createPalette(inputDto).code; 95 | return { palette, code }; 96 | } 97 | 98 | export const appActions = appSlice.actions; 99 | export default appSlice.reducer; 100 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/domain/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Subtracts two hues within a 360-degree circle and returns the result within the range of 0 to 359 hue. 3 | * 4 | * @param {number} minuend - The hue from which the subtrahend is to be subtracted. 5 | * @param {number} subtrahend - The hue to be subtracted from the minuend. 6 | * @returns {number} The result of the subtraction, adjusted to be within the range of 0 to 359 degrees. 7 | * 8 | */ 9 | export function _subtractHues(minuend: number, subtrahend: number): number { 10 | return (minuend - subtrahend + 360) % 360; 11 | } 12 | 13 | /** 14 | * Adds two hues and returns the result within the range of 0 to 359 degrees. 15 | * 16 | * @param {number} augend - The first angle (in degrees). 17 | * @param {number} addend - The second angle (in degrees). 18 | * @returns {number} The sum of the two hues, adjusted to be within the range of 0 to 359 degrees. 19 | */ 20 | export function _addHues(augend: number, addend: number): number { 21 | const result = (augend + addend) % 360; 22 | return result; 23 | } 24 | 25 | /** 26 | * Calculates the midpoint between two hues, moving clockwise. 27 | * 28 | * @param {number} firstHue 29 | * @param {number} secondHue 30 | * @returns {number} The midpoint hue. 31 | */ 32 | export function calculateClockwiseMidpoint( 33 | firstHue: number, 34 | secondHue: number 35 | ): number { 36 | const result = _addHues(firstHue, _subtractHues(secondHue, firstHue) / 2); 37 | return result; 38 | } 39 | 40 | /** 41 | * Creates array of midpoints between consecutive hues in a circular manner., clockwise direction. 42 | * 43 | * @param {number[]} hues - An array of hues. 44 | * @returns {number[]} An array of midpoints between the given hues. 45 | */ 46 | export function _createHuesMidpoints(hues: number[]): number[] { 47 | if (hues.length === 0) return []; 48 | 49 | hues.sort((a, b) => a - b); 50 | const lastAngle = hues[hues.length - 1]; 51 | hues.unshift(lastAngle); // make it circular 52 | 53 | const midpoints = []; 54 | for (let i = 0; i < hues.length - 1; i++) { 55 | const midpoint = calculateClockwiseMidpoint(hues[i], hues[i + 1]); 56 | midpoints.push(midpoint); 57 | } 58 | 59 | return midpoints; 60 | } 61 | 62 | /** 63 | * Creates hue ranges from a set of midpoints. 64 | * 65 | * This function takes an array of midpoints, sorts them in ascending order, 66 | * and then creates ranges between each consecutive pair of midpoints. 67 | * It handles the circular nature of hues by wrapping around from the last 68 | * midpoint back to the first midpoint. 69 | * 70 | * @param {number[]} midpoints - An array of midpoints. 71 | * @returns {number[][]} An array of hue ranges, where each range is represented as 72 | * a pair of start and end hue. 73 | */ 74 | export function _createHueRangesFromMidpoints(midpoints: number[]): number[][] { 75 | const firstMidpoint = midpoints[0]; 76 | midpoints.push(firstMidpoint); // make it circular 77 | 78 | const ranges = []; 79 | for (let i = 0; i < midpoints.length - 1; i++) { 80 | ranges.push([midpoints[i], midpoints[i + 1]]); 81 | } 82 | return ranges; 83 | } 84 | 85 | /** 86 | * Creates equally divided hue ranges centered around given hues. Sorted by initial hues. First pair is pair of the lowest hue, last pair is pair of the highest hue 87 | * 88 | * This function takes an array of hues and calculates midpoints between consecutive angles, 89 | * then uses these midpoints to create hue ranges that partition the circular angle space. 90 | * The ranges are represented as pairs of start and end hues, effectively dividing the circle 91 | * into segments centered around the input hues. 92 | * 93 | * @param {number[]} hues - An array of hues. 94 | * @returns {number[][]} An array of ranges, where each range is represented as a pair of start and end hues. 95 | */ 96 | export function createHueRanges(hues: number[]): number[][] { 97 | const midpoints = _createHuesMidpoints(hues); 98 | const ranges = _createHueRangesFromMidpoints(midpoints); 99 | return ranges; 100 | } 101 | -------------------------------------------------------------------------------- /src/core/domain/logic.ts: -------------------------------------------------------------------------------- 1 | ` 2 | Domain logic and rules used in creating color palette 3 | `; 4 | 5 | import { hslFromHex, hexFromHsl } from "./palette"; 6 | import { ModFactor, Range } from "./types"; 7 | import { calculateClockwiseMidpoint, _subtractHues, _addHues } from "./math"; 8 | 9 | /** 10 | * Modify the color saturation attribute value 11 | * 12 | * Current formula is based on original attribute value, possible attribute value range 13 | * and modification factor chosen by user. 14 | * Saturation attribute is presumed to be between 0 and 1. 15 | * 16 | * @param originalSaturationValue 17 | * @param satModFactor modification factor 18 | * @returns new attribute value 19 | */ 20 | export function _modifySaturationAttribute( 21 | originalSaturationValue: number, 22 | satModFactor: number 23 | ): number { 24 | let newSaturation: number; 25 | 26 | if (originalSaturationValue < 0 || originalSaturationValue > 1) { 27 | throw new Error( 28 | `original saturation value ${originalSaturationValue} is not between 0 and 1` 29 | ); 30 | } 31 | 32 | if (satModFactor === 0) { 33 | newSaturation = originalSaturationValue; 34 | } else if (satModFactor < 0) { 35 | newSaturation = 36 | originalSaturationValue - 37 | (originalSaturationValue - 0) * Math.abs(satModFactor); 38 | } else if (satModFactor > 0) { 39 | newSaturation = 40 | originalSaturationValue + (1 - originalSaturationValue) * satModFactor; 41 | } else { 42 | throw new Error(`invalid modification factor ${satModFactor}`); 43 | } 44 | 45 | return newSaturation; 46 | } 47 | 48 | /** 49 | * Modifies the hue attribute of a color. 50 | * 51 | * New hue has to be in specific range which is dependant 52 | * on colors and their hues in original framework palette 53 | * 54 | * @param {number} originalHue - The original hue value. 55 | * @param {number} minHue - The minimum hue value. 56 | * @param {number} maxHue - The maximum hue value. 57 | * @param {number} hueModFactor - The modification factor. If it's 0, the original hue is returned. If it's negative, the hue is decreased. If it's positive, the hue is increased. 58 | * @returns {number} The new hue value. 59 | */ 60 | export function _modifyHueAttribute( 61 | originalHue: number, 62 | minHue: number, 63 | maxHue: number, 64 | hueModFactor: number 65 | ): number { 66 | let newHue: number; 67 | 68 | if (hueModFactor < 0) { 69 | const delta = _subtractHues(originalHue, minHue) * Math.abs(hueModFactor); 70 | newHue = _subtractHues(originalHue, delta); 71 | } else if (hueModFactor > 0) { 72 | const delta = _subtractHues(maxHue, originalHue) * hueModFactor; 73 | newHue = _addHues(originalHue, delta); 74 | } else newHue = originalHue; 75 | 76 | return newHue; 77 | } 78 | 79 | /** 80 | * Modify hex color code by applying modification factor, respecting the limitations 81 | * for the hue and saturation value ranges 82 | * 83 | * Saturation value range is fixed, while hue range depends on the original framework palette. 84 | * 85 | * @param hexCode original hex code 86 | * @param minHue minimal possible hue for new hex 87 | * @param maxHue maximum possible hue for new hex 88 | * @param modFactor modification factor selected by user 89 | * @returns new hex code 90 | */ 91 | export function modifyHex( 92 | hexCode: string, 93 | minHue: number, 94 | maxHue: number, 95 | modFactor: ModFactor 96 | ): string { 97 | const hsl = hslFromHex(hexCode); 98 | 99 | // modify hue and sat 100 | const newHue = _modifyHueAttribute(hsl.hue, minHue, maxHue, modFactor.hueMod); 101 | 102 | const newSaturation = _modifySaturationAttribute( 103 | hsl.saturation, 104 | modFactor.satMod 105 | ); 106 | 107 | const newHex = hexFromHsl({ 108 | hue: newHue, 109 | saturation: newSaturation, 110 | luminosity: hsl.luminosity, 111 | }); 112 | 113 | return newHex; 114 | } 115 | 116 | /** 117 | * Find hue range, min and max, for the selected hue of the color palette. 118 | * @param centralHue 119 | * @param previousHue hue that comes right before central hue in hue circle, moving clockwise 120 | * @param nextHue hue that comes right after central hue in hue circle, moving clockwise 121 | * @returns possible hue range for the central hue 122 | */ 123 | export function findHueRange( 124 | centralHue: number, 125 | previousHue: number, 126 | nextHue: number 127 | ): Range { 128 | const min = calculateClockwiseMidpoint(previousHue, centralHue); 129 | const max = calculateClockwiseMidpoint(centralHue, nextHue); 130 | return { min, max }; 131 | } 132 | -------------------------------------------------------------------------------- /tests/logic.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | findHueRange, 4 | _modifySaturationAttribute, 5 | _modifyHueAttribute, 6 | modifyHex, 7 | } from "../src/core/domain/logic"; 8 | import { ModFactor } from "../src/core/domain/types"; 9 | 10 | describe("_modifyHueAttribute", () => { 11 | it("should return the original hue value when modFactor is 0", () => { 12 | const originalHue = 50; 13 | const minHue = 0; 14 | const maxHue = 360; 15 | const modFactor = 0; 16 | 17 | const result = _modifyHueAttribute(originalHue, minHue, maxHue, modFactor); 18 | 19 | const expected = 50; 20 | expect(result).toBe(expected); 21 | }); 22 | 23 | it("should decrease the hue value when modFactor is negative", () => { 24 | const originalHue = 50; 25 | const minHue = 0; 26 | const maxHue = 360; 27 | const modFactor = -0.5; 28 | 29 | const result = _modifyHueAttribute(originalHue, minHue, maxHue, modFactor); 30 | 31 | const expected = 25; 32 | expect(result).toBe(expected); 33 | }); 34 | 35 | it("should increase the hue value when modFactor is positive", () => { 36 | const originalHue = 50; 37 | const minHue = 0; 38 | const maxHue = 150; 39 | const modFactor = 0.5; 40 | 41 | const result = _modifyHueAttribute(originalHue, minHue, maxHue, modFactor); 42 | 43 | const expected = 100; 44 | expect(result).toBe(expected); 45 | }); 46 | 47 | it("should handle counter clockwise hue circle wrapping", () => { 48 | const originalHue = 50; 49 | const minHue = 200; 50 | const maxHue = 100; 51 | const modFactor = -0.5; 52 | 53 | const result = _modifyHueAttribute(originalHue, minHue, maxHue, modFactor); 54 | const expected = 305; 55 | expect(result).toBe(expected); 56 | }); 57 | it("should handle clockwise hue circle wrapping", () => { 58 | const originalHue = 300; 59 | const minHue = 200; 60 | const maxHue = 100; 61 | const modFactor = 0.5; 62 | 63 | const result = _modifyHueAttribute(originalHue, minHue, maxHue, modFactor); 64 | const expected = 20; 65 | expect(result).toBe(expected); 66 | }); 67 | }); 68 | 69 | describe("_modifySaturationAttribute", () => { 70 | it("should return the original saturation value when modFactor is 0", () => { 71 | const result = _modifySaturationAttribute(0.5, 0); 72 | expect(result).toBe(0.5); 73 | }); 74 | 75 | it("should decrease the saturation value when modFactor is negative", () => { 76 | const result = _modifySaturationAttribute(0.5, -0.5); 77 | expect(result).toBe(0.25); 78 | }); 79 | 80 | it("should increase the saturation value when modFactor is positive", () => { 81 | const result = _modifySaturationAttribute(0.5, 0.5); 82 | expect(result).toBe(0.75); 83 | }); 84 | 85 | it("should throw an error when modFactor is not a number", () => { 86 | expect(() => _modifySaturationAttribute(0.5, NaN)).toThrow(Error); 87 | }); 88 | }); 89 | 90 | describe("modify hex function tests", () => { 91 | it("should give correct hex when huemod > 0", () => { 92 | const hex = "#284277"; 93 | const minHue = 200; 94 | const maxHue = 240; 95 | const modFactor: ModFactor = { hueMod: 0.5, satMod: 0 }; 96 | 97 | const result = modifyHex(hex, minHue, maxHue, modFactor); 98 | 99 | const expected = "#283577"; 100 | 101 | expect(result).toEqual(expected); 102 | }); 103 | it("should give correct hex when min hue is before 0, max hue after 0 and result hue is after 0", () => { 104 | const hex = "#773528"; 105 | const minHue = 350; 106 | const maxHue = 30; 107 | const modFactor: ModFactor = { hueMod: 0.5, satMod: 0 }; 108 | 109 | const result = modifyHex(hex, minHue, maxHue, modFactor); 110 | 111 | const expected = "#774228"; 112 | 113 | expect(result).toEqual(expected); 114 | }); 115 | it("should give correct hex when min hue is before 0, max hue after 0 and result hue is before 0", () => { 116 | const hex = "#773528"; 117 | const minHue = 330; 118 | const maxHue = 50; 119 | const modFactor: ModFactor = { hueMod: -0.5, satMod: 0 }; 120 | 121 | const result = modifyHex(hex, minHue, maxHue, modFactor); 122 | 123 | const expected = "#772835"; 124 | 125 | expect(result).toEqual(expected); 126 | }); 127 | }); 128 | 129 | describe("findHueRange", () => { 130 | it("should calculate the correct hue range for central hue", () => { 131 | const centralHue = 50; 132 | const previousHue = 40; 133 | const nextHue = 60; 134 | const result = findHueRange(centralHue, previousHue, nextHue); 135 | expect(result.min).toBe(45); 136 | expect(result.max).toBe(55); 137 | }); 138 | 139 | it("should handle the case where the central hue is at the start of the hue circle", () => { 140 | const centralHue = 0; 141 | const previousHue = 350; 142 | const nextHue = 10; 143 | const result = findHueRange(centralHue, previousHue, nextHue); 144 | expect(result.min).toBe(355); 145 | expect(result.max).toBe(5); 146 | }); 147 | 148 | it("should handle the case where the central hue is at the end of the hue circle", () => { 149 | const centralHue = 360; 150 | const previousHue = 350; 151 | const nextHue = 10; 152 | const result = findHueRange(centralHue, previousHue, nextHue); 153 | expect(result.min).toBe(355); 154 | expect(result.max).toBe(5); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/core/frameworks/bootstrap5/data.ts: -------------------------------------------------------------------------------- 1 | import { type ColorScale } from "@/core/domain"; 2 | 3 | export const chromaticPalette: ColorScale[] = [ 4 | { 5 | colorName: "blue", 6 | order: 1, 7 | shades: [ 8 | { weight: 100, hexCode: "#cfe2ff" }, 9 | { weight: 200, hexCode: "#9ec5fe" }, 10 | { weight: 300, hexCode: "#6ea8fe" }, 11 | { weight: 400, hexCode: "#3d8bfd" }, 12 | { weight: 500, hexCode: "#0d6efd" }, 13 | { weight: 600, hexCode: "#0a58ca" }, 14 | { weight: 700, hexCode: "#084298" }, 15 | { weight: 800, hexCode: "#052c65" }, 16 | { weight: 900, hexCode: "#031633" }, 17 | ], 18 | }, 19 | { 20 | colorName: "indigo", 21 | order: 2, 22 | shades: [ 23 | { weight: 100, hexCode: "#e0cffc" }, 24 | { weight: 200, hexCode: "#c29ffa" }, 25 | { weight: 300, hexCode: "#a370f7" }, 26 | { weight: 400, hexCode: "#8540f5" }, 27 | { weight: 500, hexCode: "#6610f2" }, 28 | { weight: 600, hexCode: "#520dc2" }, 29 | { weight: 700, hexCode: "#3d0a91" }, 30 | { weight: 800, hexCode: "#290661" }, 31 | { weight: 900, hexCode: "#140330" }, 32 | ], 33 | }, 34 | { 35 | colorName: "purple", 36 | order: 3, 37 | shades: [ 38 | { weight: 100, hexCode: "#e2d9f3" }, 39 | { weight: 200, hexCode: "#c5b3e6" }, 40 | { weight: 300, hexCode: "#a98eda" }, 41 | { weight: 400, hexCode: "#8c68cd" }, 42 | { weight: 500, hexCode: "#6f42c1" }, 43 | { weight: 600, hexCode: "#59359a" }, 44 | { weight: 700, hexCode: "#432874" }, 45 | { weight: 800, hexCode: "#2c1a4d" }, 46 | { weight: 900, hexCode: "#160d27" }, 47 | ], 48 | }, 49 | { 50 | colorName: "pink", 51 | order: 4, 52 | shades: [ 53 | { weight: 100, hexCode: "#f7d6e6" }, 54 | { weight: 200, hexCode: "#efadce" }, 55 | { weight: 300, hexCode: "#e685b5" }, 56 | { weight: 400, hexCode: "#de5c9d" }, 57 | { weight: 500, hexCode: "#d63384" }, 58 | { weight: 600, hexCode: "#ab296a" }, 59 | { weight: 700, hexCode: "#801f4f" }, 60 | { weight: 800, hexCode: "#561435" }, 61 | { weight: 900, hexCode: "#2b0a1a" }, 62 | ], 63 | }, 64 | { 65 | colorName: "red", 66 | order: 5, 67 | shades: [ 68 | { weight: 100, hexCode: "#f8d7da" }, 69 | { weight: 200, hexCode: "#f1aeb5" }, 70 | { weight: 300, hexCode: "#ea868f" }, 71 | { weight: 400, hexCode: "#e35d6a" }, 72 | { weight: 500, hexCode: "#dc3545" }, 73 | { weight: 600, hexCode: "#b02a37" }, 74 | { weight: 700, hexCode: "#842029" }, 75 | { weight: 800, hexCode: "#58151c" }, 76 | { weight: 900, hexCode: "#2c0b0e" }, 77 | ], 78 | }, 79 | { 80 | colorName: "orange", 81 | order: 6, 82 | shades: [ 83 | { weight: 100, hexCode: "#ffe5d0" }, 84 | { weight: 200, hexCode: "#fecba1" }, 85 | { weight: 300, hexCode: "#feb272" }, 86 | { weight: 400, hexCode: "#fd9843" }, 87 | { weight: 500, hexCode: "#fd7e14" }, 88 | { weight: 600, hexCode: "#ca6510" }, 89 | { weight: 700, hexCode: "#984c0c" }, 90 | { weight: 800, hexCode: "#653208" }, 91 | { weight: 900, hexCode: "#331904" }, 92 | ], 93 | }, 94 | { 95 | colorName: "yellow", 96 | order: 7, 97 | shades: [ 98 | { weight: 100, hexCode: "#fff3cd" }, 99 | { weight: 200, hexCode: "#ffe69c" }, 100 | { weight: 300, hexCode: "#ffda6a" }, 101 | { weight: 400, hexCode: "#ffcd39" }, 102 | { weight: 500, hexCode: "#ffc107" }, 103 | { weight: 600, hexCode: "#cc9a06" }, 104 | { weight: 700, hexCode: "#997404" }, 105 | { weight: 800, hexCode: "#664d03" }, 106 | { weight: 900, hexCode: "#332701" }, 107 | ], 108 | }, 109 | { 110 | colorName: "green", 111 | order: 8, 112 | shades: [ 113 | { weight: 100, hexCode: "#d1e7dd" }, 114 | { weight: 200, hexCode: "#a3cfbb" }, 115 | { weight: 300, hexCode: "#75b798" }, 116 | { weight: 400, hexCode: "#479f76" }, 117 | { weight: 500, hexCode: "#198754" }, 118 | { weight: 600, hexCode: "#146c43" }, 119 | { weight: 700, hexCode: "#0f5132" }, 120 | { weight: 800, hexCode: "#0a3622" }, 121 | { weight: 900, hexCode: "#051b11" }, 122 | ], 123 | }, 124 | { 125 | colorName: "teal", 126 | order: 9, 127 | shades: [ 128 | { weight: 100, hexCode: "#d2f4ea" }, 129 | { weight: 200, hexCode: "#a6e9d5" }, 130 | { weight: 300, hexCode: "#79dfc1" }, 131 | { weight: 400, hexCode: "#4dd4ac" }, 132 | { weight: 500, hexCode: "#20c997" }, 133 | { weight: 600, hexCode: "#1aa179" }, 134 | { weight: 700, hexCode: "#13795b" }, 135 | { weight: 800, hexCode: "#0d503c" }, 136 | { weight: 900, hexCode: "#06281e" }, 137 | ], 138 | }, 139 | { 140 | colorName: "cyan", 141 | order: 10, 142 | shades: [ 143 | { weight: 100, hexCode: "#cff4fc" }, 144 | { weight: 200, hexCode: "#9eeaf9" }, 145 | { weight: 300, hexCode: "#6edff6" }, 146 | { weight: 400, hexCode: "#3dd5f3" }, 147 | { weight: 500, hexCode: "#0dcaf0" }, 148 | { weight: 600, hexCode: "#0aa2c0" }, 149 | { weight: 700, hexCode: "#087990" }, 150 | { weight: 800, hexCode: "#055160" }, 151 | { weight: 900, hexCode: "#032830" }, 152 | ], 153 | }, 154 | ]; 155 | 156 | export const neutralPalette: ColorScale[] = [ 157 | { 158 | colorName: "gray", 159 | order: 11, 160 | shades: [ 161 | { hexCode: "#f8f9fa", weight: 100 }, 162 | { weight: 200, hexCode: "#e9ecef" }, 163 | { weight: 300, hexCode: "#dee2e6" }, 164 | { weight: 400, hexCode: "#ced4da" }, 165 | { weight: 500, hexCode: "#adb5bd" }, 166 | { weight: 600, hexCode: "#6c757d" }, 167 | { weight: 700, hexCode: "#495057" }, 168 | { weight: 800, hexCode: "#343a40" }, 169 | { weight: 900, hexCode: "#212529" }, 170 | ], 171 | }, 172 | ]; 173 | -------------------------------------------------------------------------------- /tests/validators.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { 3 | validateHexColorValue, 4 | validateHue, 5 | validateLuminosity, 6 | validateSaturation, 7 | } from "../src/core/validators"; 8 | 9 | describe("hex pattern validator", () => { 10 | test("hex code #fff should be valid", () => { 11 | expect(validateHexColorValue("#fff")).toBe(true); 12 | }); 13 | 14 | test("hex code #ffffff should be valid", () => { 15 | expect(validateHexColorValue("#ffffff")).toBe(true); 16 | }); 17 | 18 | test("hex code fff should be valid", () => { 19 | expect(validateHexColorValue("fff")).toBe(true); 20 | }); 21 | 22 | test("hex code ffffff should be valid", () => { 23 | expect(validateHexColorValue("ffffff")).toBe(true); 24 | }); 25 | 26 | test("hex code #123 should be valid", () => { 27 | expect(validateHexColorValue("#123")).toBe(true); 28 | }); 29 | 30 | test("hex code #123456 should be valid", () => { 31 | expect(validateHexColorValue("#123456")).toBe(true); 32 | }); 33 | 34 | test("hex code 123 should be valid", () => { 35 | expect(validateHexColorValue("123")).toBe(true); 36 | }); 37 | 38 | test("hex code 123456 should be valid", () => { 39 | expect(validateHexColorValue("123456")).toBe(true); 40 | }); 41 | 42 | test("hex code #abc should be valid", () => { 43 | expect(validateHexColorValue("#abc")).toBe(true); 44 | }); 45 | 46 | test("hex code #abcdef should be valid", () => { 47 | expect(validateHexColorValue("#abcdef")).toBe(true); 48 | }); 49 | 50 | test("hex code abc should be valid", () => { 51 | expect(validateHexColorValue("abc")).toBe(true); 52 | }); 53 | 54 | test("hex code abcdef should be valid", () => { 55 | expect(validateHexColorValue("abcdef")).toBe(true); 56 | }); 57 | 58 | test("hex code #abcd should be invalid", () => { 59 | expect(validateHexColorValue("#abcd")).toBe(false); 60 | }); 61 | 62 | test("hex code #abcd12 should be valid", () => { 63 | expect(validateHexColorValue("#abcd12")).toBe(true); 64 | }); 65 | 66 | test("hex code #abcd123 should be invalid", () => { 67 | expect(validateHexColorValue("#abcd123")).toBe(false); 68 | }); 69 | 70 | test("hex code #1234 should be invalid", () => { 71 | expect(validateHexColorValue("#1234")).toBe(false); 72 | }); 73 | 74 | test("hex code #12345 should be invalid", () => { 75 | expect(validateHexColorValue("#12345")).toBe(false); 76 | }); 77 | 78 | test("hex code 1234 should be invalid", () => { 79 | expect(validateHexColorValue("1234")).toBe(false); 80 | }); 81 | 82 | test("hex code 12345 should be invalid", () => { 83 | expect(validateHexColorValue("12345")).toBe(false); 84 | }); 85 | 86 | test("hex code 1234567 should be invalid", () => { 87 | expect(validateHexColorValue("1234567")).toBe(false); 88 | }); 89 | 90 | test("hex code #1234567 should be invalid", () => { 91 | expect(validateHexColorValue("#1234567")).toBe(false); 92 | }); 93 | 94 | test("hex code with invalid characters #zzzzzz should be invalid", () => { 95 | expect(validateHexColorValue("#zzzzzz")).toBe(false); 96 | }); 97 | 98 | test("hex code with invalid characters ggg should be invalid", () => { 99 | expect(validateHexColorValue("ggg")).toBe(false); 100 | }); 101 | 102 | test("empty string should be invalid", () => { 103 | expect(validateHexColorValue("")).toBe(false); 104 | }); 105 | }); 106 | 107 | describe("validateLuminosity", () => { 108 | test("should return true for valid luminosity value 0", () => { 109 | expect(validateLuminosity(0)).toBe(true); 110 | }); 111 | 112 | test("should return true for valid luminosity value 0.5", () => { 113 | expect(validateLuminosity(0.5)).toBe(true); 114 | }); 115 | 116 | test("should return true for valid luminosity value 1", () => { 117 | expect(validateLuminosity(1)).toBe(true); 118 | }); 119 | 120 | test("should return false for invalid luminosity value -0.1", () => { 121 | expect(validateLuminosity(-0.1)).toBe(false); 122 | }); 123 | 124 | test("should return false for invalid luminosity value 1.1", () => { 125 | expect(validateLuminosity(1.1)).toBe(false); 126 | }); 127 | 128 | test("should return false for invalid luminosity value 2", () => { 129 | expect(validateLuminosity(2)).toBe(false); 130 | }); 131 | 132 | test("should return false for invalid luminosity value -1", () => { 133 | expect(validateLuminosity(-1)).toBe(false); 134 | }); 135 | 136 | test("should return false for NaN value", () => { 137 | expect(validateLuminosity(NaN)).toBe(false); 138 | }); 139 | 140 | test("should return false for Infinity value", () => { 141 | expect(validateLuminosity(Infinity)).toBe(false); 142 | }); 143 | 144 | test("should return false for -Infinity value", () => { 145 | expect(validateLuminosity(-Infinity)).toBe(false); 146 | }); 147 | }); 148 | 149 | describe("validateSaturation", () => { 150 | test("should return true for valid saturation value 0", () => { 151 | expect(validateSaturation(0)).toBe(true); 152 | }); 153 | 154 | test("should return true for valid saturation value 0.5", () => { 155 | expect(validateSaturation(0.5)).toBe(true); 156 | }); 157 | 158 | test("should return true for valid saturation value 1", () => { 159 | expect(validateSaturation(1)).toBe(true); 160 | }); 161 | 162 | test("should return false for saturation value less than 0", () => { 163 | expect(validateSaturation(-0.1)).toBe(false); 164 | }); 165 | 166 | test("should return false for saturation value greater than 1", () => { 167 | expect(validateSaturation(1.1)).toBe(false); 168 | }); 169 | 170 | test("should return false for saturation value 2", () => { 171 | expect(validateSaturation(2)).toBe(false); 172 | }); 173 | 174 | test("should return false for saturation value -1", () => { 175 | expect(validateSaturation(-1)).toBe(false); 176 | }); 177 | 178 | test("should return false for NaN", () => { 179 | expect(validateSaturation(NaN)).toBe(false); 180 | }); 181 | 182 | test("should return false for Infinity", () => { 183 | expect(validateSaturation(Infinity)).toBe(false); 184 | }); 185 | 186 | test("should return false for -Infinity", () => { 187 | expect(validateSaturation(-Infinity)).toBe(false); 188 | }); 189 | }); 190 | 191 | describe("validateHue", () => { 192 | test("returns true for hue within the valid range", () => { 193 | expect(validateHue(0)).toBe(true); 194 | expect(validateHue(180)).toBe(true); 195 | expect(validateHue(360)).toBe(true); 196 | }); 197 | 198 | test("returns false for hue below the valid range", () => { 199 | expect(validateHue(-1)).toBe(false); 200 | expect(validateHue(-10)).toBe(false); 201 | }); 202 | 203 | test("returns false for hue above the valid range", () => { 204 | expect(validateHue(361)).toBe(false); 205 | expect(validateHue(400)).toBe(false); 206 | }); 207 | 208 | test("returns true for edge cases", () => { 209 | expect(validateHue(0)).toBe(true); 210 | expect(validateHue(360)).toBe(true); 211 | }); 212 | 213 | test("returns false for non-integer values", () => { 214 | expect(validateHue(360.1)).toBe(false); 215 | expect(validateHue(-0.1)).toBe(false); 216 | }); 217 | 218 | test("returns false for non-numeric values", () => { 219 | expect(validateHue(NaN)).toBe(false); 220 | expect(validateHue(Infinity)).toBe(false); 221 | expect(validateHue(-Infinity)).toBe(false); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /src/core/domain/transformations.ts: -------------------------------------------------------------------------------- 1 | ` 2 | Palette transformation, with transitioning types. 3 | `; 4 | import { type ColorScale, type ModFactor } from "./types"; 5 | import { hslFromHex } from "./palette"; 6 | import { findHueRange, modifyHex } from "./logic"; 7 | 8 | /** 9 | * Flattens an array of ColorScale objects, the palette 10 | * 11 | * @param {ColorScale[]} palette - The array of ColorScale objects to flatten. 12 | * @returns {FlattenedColorScale[]} The flattened array of FlattenedColorScale objects. 13 | */ 14 | export function flatten(palette: ColorScale[]): FlattenedColorScale[] { 15 | return palette.flatMap(color => 16 | color.shades.map(shade => ({ 17 | colorName: color.colorName, 18 | order: color.order, 19 | hexCode: shade.hexCode, 20 | weight: shade.weight, 21 | })) 22 | ); 23 | } 24 | 25 | /** 26 | * Extracts the distinct weights from an array of flattened palette. 27 | * 28 | * @param data - An array of FlattenedColorScale objects. 29 | * @returns An array of distinct weights. 30 | */ 31 | export function getDistinctWeights(data: FlattenedColorScale[]): number[] { 32 | const weights = new Set(data.map(item => item.weight)); 33 | return Array.from(weights); 34 | } 35 | 36 | /** 37 | * Add hue attribue to the flattened color scale which comes from hex code 38 | * @param data flattened color scale 39 | * @returns flattened color scale with added hue 40 | */ 41 | export function addHue(data: FlattenedColorScale[]): WithHue[] { 42 | const addedHues = data.map(x => { 43 | const hue = hslFromHex(x.hexCode).hue; 44 | return { ...x, hue }; 45 | }); 46 | 47 | return addedHues; 48 | } 49 | 50 | /** 51 | * Filter flattened pallete by weight 52 | * @param data flattened color palette 53 | * @param weight 54 | * @returns colors with chosen weight 55 | */ 56 | export function filterByWeight( 57 | data: FlattenedColorScale[], 58 | weight: number 59 | ): FlattenedColorScale[] { 60 | const filtered = data.filter(x => x.weight === weight); 61 | return filtered; 62 | } 63 | 64 | /** 65 | * Validates that all elements in the input array have the same weight. 66 | * 67 | * @param data - An array of WithHue objects. 68 | * @throws {Error} Throws an error if all elements do not have the same weight. 69 | */ 70 | export function validateAllWeightsAreEqual(data: WithHue[]): void { 71 | const weights = new Set(data.map(x => x.weight)); 72 | if (weights.size > 1) { 73 | throw new Error("weights are not equal"); 74 | } 75 | } 76 | 77 | /** 78 | * Sorts an array of objects with a 'hue' attribute in ascending order. 79 | * Throws an error if the weights of the objects are not equal. 80 | * 81 | * @param data - An array of objects with a 'hue' and 'weight' attribute. 82 | * @returns The input array sorted by hue in ascending order. 83 | * @throws {Error} When the weights of the objects in the input array are not equal. 84 | */ 85 | export function sortByHue(data: WithHue[]): WithHue[] { 86 | validateAllWeightsAreEqual(data); 87 | const sorted = data.sort((a, b) => a.hue - b.hue); 88 | return sorted; 89 | } 90 | 91 | /** 92 | * Validates that the input array is sorted by hue in ascending order. 93 | * Throws an error if the array is not sorted in this way. 94 | * 95 | * @param data - An array of WithHue objects. 96 | * @throws {Error} Will throw an error if the array is not sorted by hue in ascending order. 97 | */ 98 | export function validateIsSortedByHue(data: WithHue[]): void { 99 | data.forEach((item, i) => { 100 | if (i > 0 && item.hue < data[i - 1].hue) { 101 | throw new Error("array is not sorted by hue ascending"); 102 | } 103 | }); 104 | } 105 | 106 | /** 107 | * This function adds adjacent hues to each item in the flattened palette array. The adjacent hues are the hues of the previous and next items in the array. 108 | * It follows the circular nature of hue circle. 109 | * Array needs to be sorted by hue ascending 110 | * 111 | * @param {WithHue[]} data - The array of items with hue. 112 | * @returns {WithAdjancentHues[]} The array of items with adjacent hues. 113 | */ 114 | export function addAdjancentHues(data: WithHue[]): WithAdjancentHues[] { 115 | validateIsSortedByHue(data); 116 | return data.map((item, index, array) => { 117 | const prevIndex = index === 0 ? array.length - 1 : index - 1; 118 | const nextIndex = index === array.length - 1 ? 0 : index + 1; 119 | 120 | return { 121 | ...item, 122 | prevHue: array[prevIndex].hue, 123 | nextHue: array[nextIndex].hue, 124 | }; 125 | }); 126 | } 127 | 128 | /** 129 | * Adds hue ranges to the flattened palette with adjancent hues 130 | * 131 | * @param data flattened palette 132 | * @returns flattened palette with added hue ranges 133 | */ 134 | export function addHueRanges(data: WithAdjancentHues[]): WithHueRange[] { 135 | return data.map(x => { 136 | const hueRange = findHueRange(x.hue, x.prevHue, x.nextHue); 137 | return { ...x, minHue: hueRange.min, maxHue: hueRange.max }; 138 | }); 139 | } 140 | 141 | /** 142 | * Adds new, modified hex code to the flattened palette with hue ranges 143 | * 144 | * @param data - flattened palette 145 | * @param hueMod - A number representing the hue modification factor 146 | * @param saturationMod - A number representing the saturation modification factor 147 | * @returns flattened palette with added modified hex code 148 | */ 149 | export function addModifiedHex( 150 | data: WithHueRange[], 151 | modFactor: ModFactor 152 | ): WithModifiedHex[] { 153 | return data.map(x => { 154 | const modifiedHex = modifyHex(x.hexCode, x.minHue, x.maxHue, modFactor); 155 | return { ...x, modifiedHex }; 156 | }); 157 | } 158 | 159 | export function transformToColorScale(data: WithModifiedHex[]): ColorScale[] { 160 | const resultMap: { 161 | [key: string]: { weight: number; hexCode: string; order: number }[]; 162 | } = {}; 163 | 164 | data.forEach(item => { 165 | if (!resultMap[item.colorName]) { 166 | resultMap[item.colorName] = []; 167 | } 168 | resultMap[item.colorName].push({ 169 | weight: item.weight, 170 | hexCode: item.modifiedHex, 171 | order: item.order, 172 | }); 173 | }); 174 | 175 | return Object.keys(resultMap).map(colorName => { 176 | const shades = resultMap[colorName].sort((a, b) => a.weight - b.weight); 177 | const order = resultMap[colorName][0].order; // Assume order is the same for all shades of the same color 178 | 179 | return { 180 | _tag: "ChromaticColorScale", 181 | colorName, 182 | order, 183 | shades: shades.map(shade => ({ 184 | weight: shade.weight, 185 | hexCode: shade.hexCode, 186 | })), 187 | }; 188 | }); 189 | } 190 | 191 | export type FlattenedColorScale = { 192 | colorName: string; 193 | order: number; 194 | weight: number; 195 | hexCode: string; 196 | }; 197 | 198 | /** 199 | * Represents flattened color palette with added hue which is extracted from hex 200 | */ 201 | export type WithHue = FlattenedColorScale & { hue: number }; 202 | 203 | /** 204 | * Represents flattened palette with added target hue which is extracted from hex 205 | * and added hues wich are adjancent to the target hue as appear in 206 | * hue circle for the same weight 207 | */ 208 | export type WithAdjancentHues = WithHue & { prevHue: number; nextHue: number }; 209 | 210 | /** 211 | * Flattened palette with added hue ranges for modified hue. Hue range is between min and max hue. 212 | */ 213 | export type WithHueRange = WithAdjancentHues & { 214 | minHue: number; 215 | maxHue: number; 216 | }; 217 | 218 | /** 219 | * Containes modified hex 220 | */ 221 | export type WithModifiedHex = WithHueRange & { modifiedHex: string }; 222 | -------------------------------------------------------------------------------- /tests/math.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { 3 | _addHues, 4 | calculateClockwiseMidpoint, 5 | _createHuesMidpoints, 6 | createHueRanges, 7 | _createHueRangesFromMidpoints, 8 | _subtractHues, 9 | } from "../src/core/domain/math"; 10 | 11 | describe("subtractCircleDegrees", () => { 12 | it("should return correct result for basic subtraction within range", () => { 13 | expect(_subtractHues(50, 20)).toBe(30); 14 | expect(_subtractHues(180, 90)).toBe(90); 15 | expect(_subtractHues(359, 1)).toBe(358); 16 | }); 17 | 18 | it("should handle subtraction with wrap-around correctly", () => { 19 | expect(_subtractHues(10, 20)).toBe(350); 20 | expect(_subtractHues(0, 180)).toBe(180); 21 | expect(_subtractHues(1, 359)).toBe(2); 22 | expect(_subtractHues(10, 350)).toBe(20); 23 | }); 24 | 25 | it("should return 0 when subtracting the same number", () => { 26 | expect(_subtractHues(0, 0)).toBe(0); 27 | expect(_subtractHues(180, 180)).toBe(0); 28 | expect(_subtractHues(359, 359)).toBe(0); 29 | expect(_subtractHues(360, 360)).toBe(0); 30 | }); 31 | 32 | it("should handle edge cases", () => { 33 | expect(_subtractHues(0, 1)).toBe(359); 34 | expect(_subtractHues(1, 0)).toBe(1); 35 | expect(_subtractHues(360, 0)).toBe(0); // Note: 360 % 360 is 0 36 | expect(_subtractHues(360, 360)).toBe(0); // Note: 360 % 360 is 0 37 | }); 38 | }); 39 | 40 | describe("addAngles", () => { 41 | it("should add two angles correctly without wrapping", () => { 42 | expect(_addHues(30, 40)).toBe(70); 43 | expect(_addHues(0, 90)).toBe(90); 44 | expect(_addHues(180, 180)).toBe(0); 45 | }); 46 | 47 | it("should add two angles correctly with wrapping", () => { 48 | expect(_addHues(350, 20)).toBe(10); 49 | expect(_addHues(270, 180)).toBe(90); 50 | expect(_addHues(359, 2)).toBe(1); 51 | }); 52 | 53 | it("should handle edge cases", () => { 54 | expect(_addHues(0, 0)).toBe(0); 55 | expect(_addHues(0, 360)).toBe(0); 56 | expect(_addHues(360, 0)).toBe(0); 57 | expect(_addHues(360, 360)).toBe(0); 58 | expect(_addHues(359, 1)).toBe(0); 59 | }); 60 | }); 61 | 62 | describe("calculateClockwiseMidpoint", () => { 63 | it("should calculate the correct clockwise midpoint", () => { 64 | expect(calculateClockwiseMidpoint(50, 30)).toBe(220); 65 | expect(calculateClockwiseMidpoint(30, 50)).toBe(40); 66 | expect(calculateClockwiseMidpoint(0, 180)).toBe(90); 67 | expect(calculateClockwiseMidpoint(275, 25)).toBe(330); 68 | }); 69 | 70 | it("should handle wrap-around correctly", () => { 71 | expect(calculateClockwiseMidpoint(350, 10)).toBe(0); // Midpoint between 350 and 10 clockwise 72 | expect(calculateClockwiseMidpoint(10, 350)).toBe(180); // Midpoint between 10 and 350 clockwise 73 | }); 74 | 75 | it("should handle identical start and end degrees correctly", () => { 76 | expect(calculateClockwiseMidpoint(0, 0)).toBe(0); 77 | expect(calculateClockwiseMidpoint(180, 180)).toBe(180); 78 | expect(calculateClockwiseMidpoint(359, 359)).toBe(359); 79 | }); 80 | 81 | it("should handle edge cases correctly", () => { 82 | expect(calculateClockwiseMidpoint(0, 359)).toBe(179.5); // Midpoint between 0 and 359 clockwise 83 | expect(calculateClockwiseMidpoint(359, 0)).toBe(359.5); // Midpoint between 359 and 0 clockwise 84 | expect(calculateClockwiseMidpoint(1, 360)).toBe(180.5); // Midpoint between 1 and 360 (which is 0) 85 | }); 86 | }); 87 | 88 | describe("createAnglesMidpoints", () => { 89 | it("should calculate midpoints for a simple set of angles", () => { 90 | const angles = [10, 50, 170]; 91 | const expectedMidpoints = [270, 30, 110]; // Midpoints calculated manually 92 | 93 | expect(_createHuesMidpoints(angles)).toEqual(expectedMidpoints); 94 | }); 95 | 96 | it("should calculate midpoints for a set of angles with wrap-around", () => { 97 | const angles = [350, 10, 50]; 98 | const expectedMidpoints = [0, 30, 200]; // Midpoints calculated manually 99 | 100 | expect(_createHuesMidpoints(angles)).toEqual(expectedMidpoints); 101 | }); 102 | 103 | it("should handle a single angle correctly", () => { 104 | const angles = [100]; 105 | const expectedMidpoints = [100]; // Only one angle, the midpoint is the angle itself 106 | 107 | expect(_createHuesMidpoints(angles)).toEqual(expectedMidpoints); 108 | }); 109 | 110 | it("should handle two angles correctly", () => { 111 | const angles = [90, 270]; 112 | const expectedMidpoints = [0, 180]; // Midpoints calculated manually 113 | 114 | expect(_createHuesMidpoints(angles)).toEqual(expectedMidpoints); 115 | }); 116 | it("should return empty array", () => { 117 | const angles: number[] = []; 118 | const expectedMidpoints: number[] = []; // Midpoints calculated manually 119 | 120 | expect(_createHuesMidpoints(angles)).toEqual(expectedMidpoints); 121 | }); 122 | }); 123 | 124 | describe("createRangesFromMidpoints", () => { 125 | it("should create ranges for a simple set of midpoints", () => { 126 | const midpoints = [30, 70, 110]; 127 | const expectedRanges = [ 128 | [30, 70], 129 | [70, 110], 130 | [110, 30], 131 | ]; 132 | 133 | expect(_createHueRangesFromMidpoints(midpoints)).toEqual(expectedRanges); 134 | }); 135 | 136 | it("should handle a single midpoint correctly", () => { 137 | const midpoints = [90]; 138 | const expectedRanges = [[90, 90]]; 139 | 140 | expect(_createHueRangesFromMidpoints(midpoints)).toEqual(expectedRanges); 141 | }); 142 | 143 | it("should handle two midpoints correctly", () => { 144 | const midpoints = [0, 180]; 145 | const expectedRanges = [ 146 | [0, 180], 147 | [180, 0], 148 | ]; 149 | 150 | expect(_createHueRangesFromMidpoints(midpoints)).toEqual(expectedRanges); 151 | }); 152 | 153 | it("should return an empty array when given an empty array of midpoints", () => { 154 | const midpoints: number[] = []; 155 | const expectedRanges: number[][] = []; 156 | 157 | expect(_createHueRangesFromMidpoints(midpoints)).toEqual(expectedRanges); 158 | }); 159 | 160 | it("should handle midpoints with wrap-around correctly", () => { 161 | const midpoints = [350, 10, 50]; 162 | const expectedRanges = [ 163 | [350, 10], 164 | [10, 50], 165 | [50, 350], 166 | ]; 167 | 168 | expect(_createHueRangesFromMidpoints(midpoints)).toEqual(expectedRanges); 169 | }); 170 | }); 171 | 172 | describe("createAnglesRanges", () => { 173 | it("should create ranges for a simple set of angles", () => { 174 | const angles = [30, 70, 110]; 175 | const expectedRanges = [ 176 | [250, 50], 177 | [50, 90], 178 | [90, 250], 179 | ]; 180 | expect(createHueRanges(angles)).toEqual(expectedRanges); 181 | }); 182 | 183 | it("should handle a single angle correctly", () => { 184 | const angles = [90]; 185 | const expectedRanges = [[90, 90]]; 186 | expect(createHueRanges(angles)).toEqual(expectedRanges); 187 | }); 188 | 189 | it("should handle two angles correctly", () => { 190 | const angles = [0, 180]; 191 | const expectedRanges = [ 192 | [270, 90], 193 | [90, 270], 194 | ]; 195 | expect(createHueRanges(angles)).toEqual(expectedRanges); 196 | }); 197 | 198 | it("should return an empty array when given an empty array of angles", () => { 199 | const angles: number[] = []; 200 | const expectedRanges: number[][] = []; 201 | expect(createHueRanges(angles)).toEqual(expectedRanges); 202 | }); 203 | 204 | it("should handle angles with wrap-around correctly", () => { 205 | const angles = [350, 10, 50]; 206 | const expectedRanges = [ 207 | [0, 30], 208 | [30, 200], 209 | [200, 0], 210 | ]; 211 | expect(createHueRanges(angles)).toEqual(expectedRanges); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /src/core/frameworks/tailwind3/data.ts: -------------------------------------------------------------------------------- 1 | ` 2 | Tailwind 3 specific data 3 | `; 4 | import { type ColorScale } from "../../domain/types"; 5 | 6 | export const neutralPalette: ColorScale[] = [ 7 | { 8 | colorName: "slate", 9 | order: 18, 10 | shades: [ 11 | { weight: 50, hexCode: "#f8fafc" }, 12 | { weight: 100, hexCode: "#f1f5f9" }, 13 | { weight: 200, hexCode: "#e2e8f0" }, 14 | { weight: 300, hexCode: "#cbd5e1" }, 15 | { weight: 400, hexCode: "#94a3b8" }, 16 | { weight: 500, hexCode: "#64748b" }, 17 | { weight: 600, hexCode: "#475569" }, 18 | { weight: 700, hexCode: "#334155" }, 19 | { weight: 800, hexCode: "#1e293b" }, 20 | { weight: 900, hexCode: "#0f172a" }, 21 | { weight: 950, hexCode: "#020617" }, 22 | ], 23 | }, 24 | { 25 | colorName: "gray", 26 | order: 19, 27 | shades: [ 28 | { weight: 50, hexCode: "#f9fafb" }, 29 | { weight: 100, hexCode: "#f3f4f6" }, 30 | { weight: 200, hexCode: "#e5e7eb" }, 31 | { weight: 300, hexCode: "#d1d5db" }, 32 | { weight: 400, hexCode: "#9ca3af" }, 33 | { weight: 500, hexCode: "#6b7280" }, 34 | { weight: 600, hexCode: "#4b5563" }, 35 | { weight: 700, hexCode: "#374151" }, 36 | { weight: 800, hexCode: "#1f2937" }, 37 | { weight: 900, hexCode: "#111827" }, 38 | { weight: 950, hexCode: "#030712" }, 39 | ], 40 | }, 41 | { 42 | colorName: "zinc", 43 | order: 20, 44 | shades: [ 45 | { weight: 50, hexCode: "#fafafa" }, 46 | { weight: 100, hexCode: "#f4f4f5" }, 47 | { weight: 200, hexCode: "#e4e4e7" }, 48 | { weight: 300, hexCode: "#d4d4d8" }, 49 | { weight: 400, hexCode: "#a1a1aa" }, 50 | { weight: 500, hexCode: "#71717a" }, 51 | { weight: 600, hexCode: "#52525b" }, 52 | { weight: 700, hexCode: "#3f3f46" }, 53 | { weight: 800, hexCode: "#27272a" }, 54 | { weight: 900, hexCode: "#18181b" }, 55 | { weight: 950, hexCode: "#09090b" }, 56 | ], 57 | }, 58 | { 59 | colorName: "neutral", 60 | order: 21, 61 | shades: [ 62 | { weight: 50, hexCode: "#fafafa" }, 63 | { weight: 100, hexCode: "#f5f5f5" }, 64 | { weight: 200, hexCode: "#e5e5e5" }, 65 | { weight: 300, hexCode: "#d4d4d4" }, 66 | { weight: 400, hexCode: "#a3a3a3" }, 67 | { weight: 500, hexCode: "#737373" }, 68 | { weight: 600, hexCode: "#525252" }, 69 | { weight: 700, hexCode: "#404040" }, 70 | { weight: 800, hexCode: "#262626" }, 71 | { weight: 900, hexCode: "#171717" }, 72 | { weight: 950, hexCode: "#0a0a0a" }, 73 | ], 74 | }, 75 | { 76 | colorName: "stone", 77 | order: 22, 78 | shades: [ 79 | { weight: 50, hexCode: "#fafaf9" }, 80 | { weight: 100, hexCode: "#f5f5f4" }, 81 | { weight: 200, hexCode: "#e7e5e4" }, 82 | { weight: 300, hexCode: "#d6d3d1" }, 83 | { weight: 400, hexCode: "#a8a29e" }, 84 | { weight: 500, hexCode: "#78716c" }, 85 | { weight: 600, hexCode: "#57534e" }, 86 | { weight: 700, hexCode: "#44403c" }, 87 | { weight: 800, hexCode: "#292524" }, 88 | { weight: 900, hexCode: "#1c1917" }, 89 | { weight: 950, hexCode: "#0c0a09" }, 90 | ], 91 | }, 92 | ]; 93 | 94 | /** 95 | * Palette which includes only chromatic colors. 96 | * 97 | * Not neutrals. Neutrals are treated separately. 98 | */ 99 | export const chromaticPalette: ColorScale[] = [ 100 | { 101 | colorName: "red", 102 | order: 1, 103 | shades: [ 104 | { weight: 50, hexCode: "#fef2f2" }, 105 | { weight: 100, hexCode: "#fee2e2" }, 106 | { weight: 200, hexCode: "#fecaca" }, 107 | { weight: 300, hexCode: "#fca5a5" }, 108 | { weight: 400, hexCode: "#f87171" }, 109 | { weight: 500, hexCode: "#ef4444" }, 110 | { weight: 600, hexCode: "#dc2626" }, 111 | { weight: 700, hexCode: "#b91c1c" }, 112 | { weight: 800, hexCode: "#991b1b" }, 113 | { weight: 900, hexCode: "#7f1d1d" }, 114 | { weight: 950, hexCode: "#450a0a" }, 115 | ], 116 | }, 117 | { 118 | colorName: "orange", 119 | order: 2, 120 | shades: [ 121 | { weight: 50, hexCode: "#fff7ed" }, 122 | { weight: 100, hexCode: "#ffedd5" }, 123 | { weight: 200, hexCode: "#fed7aa" }, 124 | { weight: 300, hexCode: "#fdba74" }, 125 | { weight: 400, hexCode: "#fb923c" }, 126 | { weight: 500, hexCode: "#f97316" }, 127 | { weight: 600, hexCode: "#ea580c" }, 128 | { weight: 700, hexCode: "#c2410c" }, 129 | { weight: 800, hexCode: "#9a3412" }, 130 | { weight: 900, hexCode: "#7c2d12" }, 131 | { weight: 950, hexCode: "#431407" }, 132 | ], 133 | }, 134 | { 135 | colorName: "amber", 136 | order: 3, 137 | shades: [ 138 | { weight: 50, hexCode: "#fffbeb" }, 139 | { weight: 100, hexCode: "#fef3c7" }, 140 | { weight: 200, hexCode: "#fde68a" }, 141 | { weight: 300, hexCode: "#fcd34d" }, 142 | { weight: 400, hexCode: "#fbbf24" }, 143 | { weight: 500, hexCode: "#f59e0b" }, 144 | { weight: 600, hexCode: "#d97706" }, 145 | { weight: 700, hexCode: "#b45309" }, 146 | { weight: 800, hexCode: "#92400e" }, 147 | { weight: 900, hexCode: "#78350f" }, 148 | { weight: 950, hexCode: "#451a03" }, 149 | ], 150 | }, 151 | { 152 | colorName: "yellow", 153 | order: 4, 154 | shades: [ 155 | { weight: 50, hexCode: "#fefce8" }, 156 | { weight: 100, hexCode: "#fef9c3" }, 157 | { weight: 200, hexCode: "#fef08a" }, 158 | { weight: 300, hexCode: "#fde047" }, 159 | { weight: 400, hexCode: "#facc15" }, 160 | { weight: 500, hexCode: "#eab308" }, 161 | { weight: 600, hexCode: "#ca8a04" }, 162 | { weight: 700, hexCode: "#a16207" }, 163 | { weight: 800, hexCode: "#854d0e" }, 164 | { weight: 900, hexCode: "#713f12" }, 165 | { weight: 950, hexCode: "#422006" }, 166 | ], 167 | }, 168 | { 169 | colorName: "lime", 170 | order: 5, 171 | shades: [ 172 | { weight: 50, hexCode: "#f7fee7" }, 173 | { weight: 100, hexCode: "#ecfccb" }, 174 | { weight: 200, hexCode: "#d9f99d" }, 175 | { weight: 300, hexCode: "#bef264" }, 176 | { weight: 400, hexCode: "#a3e635" }, 177 | { weight: 500, hexCode: "#84cc16" }, 178 | { weight: 600, hexCode: "#65a30d" }, 179 | { weight: 700, hexCode: "#4d7c0f" }, 180 | { weight: 800, hexCode: "#3f6212" }, 181 | { weight: 900, hexCode: "#365314" }, 182 | { weight: 950, hexCode: "#1a2e05" }, 183 | ], 184 | }, 185 | { 186 | colorName: "green", 187 | order: 6, 188 | shades: [ 189 | { weight: 50, hexCode: "#f0fdf4" }, 190 | { weight: 100, hexCode: "#dcfce7" }, 191 | { weight: 200, hexCode: "#bbf7d0" }, 192 | { weight: 300, hexCode: "#86efac" }, 193 | { weight: 400, hexCode: "#4ade80" }, 194 | { weight: 500, hexCode: "#22c55e" }, 195 | { weight: 600, hexCode: "#16a34a" }, 196 | { weight: 700, hexCode: "#15803d" }, 197 | { weight: 800, hexCode: "#166534" }, 198 | { weight: 900, hexCode: "#14532d" }, 199 | { weight: 950, hexCode: "#052e16" }, 200 | ], 201 | }, 202 | { 203 | colorName: "emerald", 204 | order: 7, 205 | shades: [ 206 | { weight: 50, hexCode: "#ecfdf5" }, 207 | { weight: 100, hexCode: "#d1fae5" }, 208 | { weight: 200, hexCode: "#a7f3d0" }, 209 | { weight: 300, hexCode: "#6ee7b7" }, 210 | { weight: 400, hexCode: "#34d399" }, 211 | { weight: 500, hexCode: "#10b981" }, 212 | { weight: 600, hexCode: "#059669" }, 213 | { weight: 700, hexCode: "#047857" }, 214 | { weight: 800, hexCode: "#065f46" }, 215 | { weight: 900, hexCode: "#064e3b" }, 216 | { weight: 950, hexCode: "#022c22" }, 217 | ], 218 | }, 219 | { 220 | colorName: "teal", 221 | order: 8, 222 | shades: [ 223 | { weight: 50, hexCode: "#f0fdfa" }, 224 | { weight: 100, hexCode: "#ccfbf1" }, 225 | { weight: 200, hexCode: "#99f6e4" }, 226 | { weight: 300, hexCode: "#5eead4" }, 227 | { weight: 400, hexCode: "#2dd4bf" }, 228 | { weight: 500, hexCode: "#14b8a6" }, 229 | { weight: 600, hexCode: "#0d9488" }, 230 | { weight: 700, hexCode: "#0f766e" }, 231 | { weight: 800, hexCode: "#115e59" }, 232 | { weight: 900, hexCode: "#134e4a" }, 233 | { weight: 950, hexCode: "#042f2e" }, 234 | ], 235 | }, 236 | { 237 | colorName: "cyan", 238 | order: 9, 239 | shades: [ 240 | { weight: 50, hexCode: "#ecfeff" }, 241 | { weight: 100, hexCode: "#cffafe" }, 242 | { weight: 200, hexCode: "#a5f3fc" }, 243 | { weight: 300, hexCode: "#67e8f9" }, 244 | { weight: 400, hexCode: "#22d3ee" }, 245 | { weight: 500, hexCode: "#06b6d4" }, 246 | { weight: 600, hexCode: "#0891b2" }, 247 | { weight: 700, hexCode: "#0e7490" }, 248 | { weight: 800, hexCode: "#155e75" }, 249 | { weight: 900, hexCode: "#164e63" }, 250 | { weight: 950, hexCode: "#083344" }, 251 | ], 252 | }, 253 | { 254 | colorName: "sky", 255 | order: 10, 256 | shades: [ 257 | { weight: 50, hexCode: "#f0f9ff" }, 258 | { weight: 100, hexCode: "#e0f2fe" }, 259 | { weight: 200, hexCode: "#bae6fd" }, 260 | { weight: 300, hexCode: "#7dd3fc" }, 261 | { weight: 400, hexCode: "#38bdf8" }, 262 | { weight: 500, hexCode: "#0ea5e9" }, 263 | { weight: 600, hexCode: "#0284c7" }, 264 | { weight: 700, hexCode: "#0369a1" }, 265 | { weight: 800, hexCode: "#075985" }, 266 | { weight: 900, hexCode: "#0c4a6e" }, 267 | { weight: 950, hexCode: "#082f49" }, 268 | ], 269 | }, 270 | { 271 | colorName: "blue", 272 | order: 11, 273 | shades: [ 274 | { weight: 50, hexCode: "#eff6ff" }, 275 | { weight: 100, hexCode: "#dbeafe" }, 276 | { weight: 200, hexCode: "#bfdbfe" }, 277 | { weight: 300, hexCode: "#93c5fd" }, 278 | { weight: 400, hexCode: "#60a5fa" }, 279 | { weight: 500, hexCode: "#3b82f6" }, 280 | { weight: 600, hexCode: "#2563eb" }, 281 | { weight: 700, hexCode: "#1d4ed8" }, 282 | { weight: 800, hexCode: "#1e40af" }, 283 | { weight: 900, hexCode: "#1e3a8a" }, 284 | { weight: 950, hexCode: "#172554" }, 285 | ], 286 | }, 287 | { 288 | colorName: "indigo", 289 | order: 12, 290 | shades: [ 291 | { weight: 50, hexCode: "#eef2ff" }, 292 | { weight: 100, hexCode: "#e0e7ff" }, 293 | { weight: 200, hexCode: "#c7d2fe" }, 294 | { weight: 300, hexCode: "#a5b4fc" }, 295 | { weight: 400, hexCode: "#818cf8" }, 296 | { weight: 500, hexCode: "#6366f1" }, 297 | { weight: 600, hexCode: "#4f46e5" }, 298 | { weight: 700, hexCode: "#4338ca" }, 299 | { weight: 800, hexCode: "#3730a3" }, 300 | { weight: 900, hexCode: "#312e81" }, 301 | { weight: 950, hexCode: "#1e1b4b" }, 302 | ], 303 | }, 304 | { 305 | colorName: "violet", 306 | order: 13, 307 | shades: [ 308 | { weight: 50, hexCode: "#f5f3ff" }, 309 | { weight: 100, hexCode: "#ede9fe" }, 310 | { weight: 200, hexCode: "#ddd6fe" }, 311 | { weight: 300, hexCode: "#c4b5fd" }, 312 | { weight: 400, hexCode: "#a78bfa" }, 313 | { weight: 500, hexCode: "#8b5cf6" }, 314 | { weight: 600, hexCode: "#7c3aed" }, 315 | { weight: 700, hexCode: "#6d28d9" }, 316 | { weight: 800, hexCode: "#5b21b6" }, 317 | { weight: 900, hexCode: "#4c1d95" }, 318 | { weight: 950, hexCode: "#2e1065" }, 319 | ], 320 | }, 321 | { 322 | colorName: "purple", 323 | order: 14, 324 | shades: [ 325 | { weight: 50, hexCode: "#faf5ff" }, 326 | { weight: 100, hexCode: "#f3e8ff" }, 327 | { weight: 200, hexCode: "#e9d5ff" }, 328 | { weight: 300, hexCode: "#d8b4fe" }, 329 | { weight: 400, hexCode: "#c084fc" }, 330 | { weight: 500, hexCode: "#a855f7" }, 331 | { weight: 600, hexCode: "#9333ea" }, 332 | { weight: 700, hexCode: "#7e22ce" }, 333 | { weight: 800, hexCode: "#6b21a8" }, 334 | { weight: 900, hexCode: "#581c87" }, 335 | { weight: 950, hexCode: "#3b0764" }, 336 | ], 337 | }, 338 | { 339 | colorName: "fuchsia", 340 | order: 15, 341 | shades: [ 342 | { weight: 50, hexCode: "#fdf4ff" }, 343 | { weight: 100, hexCode: "#fae8ff" }, 344 | { weight: 200, hexCode: "#f5d0fe" }, 345 | { weight: 300, hexCode: "#f0abfc" }, 346 | { weight: 400, hexCode: "#e879f9" }, 347 | { weight: 500, hexCode: "#d946ef" }, 348 | { weight: 600, hexCode: "#c026d3" }, 349 | { weight: 700, hexCode: "#a21caf" }, 350 | { weight: 800, hexCode: "#86198f" }, 351 | { weight: 900, hexCode: "#701a75" }, 352 | { weight: 950, hexCode: "#4a044e" }, 353 | ], 354 | }, 355 | { 356 | colorName: "pink", 357 | order: 16, 358 | shades: [ 359 | { weight: 50, hexCode: "#fdf2f8" }, 360 | { weight: 100, hexCode: "#fce7f3" }, 361 | { weight: 200, hexCode: "#fbcfe8" }, 362 | { weight: 300, hexCode: "#f9a8d4" }, 363 | { weight: 400, hexCode: "#f472b6" }, 364 | { weight: 500, hexCode: "#ec4899" }, 365 | { weight: 600, hexCode: "#db2777" }, 366 | { weight: 700, hexCode: "#be185d" }, 367 | { weight: 800, hexCode: "#9d174d" }, 368 | { weight: 900, hexCode: "#831843" }, 369 | { weight: 950, hexCode: "#500724" }, 370 | ], 371 | }, 372 | { 373 | colorName: "rose", 374 | order: 17, 375 | shades: [ 376 | { weight: 50, hexCode: "#fff1f2" }, 377 | { weight: 100, hexCode: "#ffe4e6" }, 378 | { weight: 200, hexCode: "#fecdd3" }, 379 | { weight: 300, hexCode: "#fda4af" }, 380 | { weight: 400, hexCode: "#fb7185" }, 381 | { weight: 500, hexCode: "#f43f5e" }, 382 | { weight: 600, hexCode: "#e11d48" }, 383 | { weight: 700, hexCode: "#be123c" }, 384 | { weight: 800, hexCode: "#9f1239" }, 385 | { weight: 900, hexCode: "#881337" }, 386 | { weight: 950, hexCode: "#4c0519" }, 387 | ], 388 | }, 389 | ]; 390 | -------------------------------------------------------------------------------- /tests/transformations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, it } from "vitest"; 2 | import { 3 | type FlattenedColorScale, 4 | type WithHue, 5 | type WithAdjancentHues, 6 | type WithHueRange, 7 | type WithModifiedHex, 8 | flatten, 9 | filterByWeight, 10 | addHue, 11 | validateAllWeightsAreEqual, 12 | sortByHue, 13 | validateIsSortedByHue, 14 | addAdjancentHues, 15 | getDistinctWeights, 16 | addHueRanges, 17 | addModifiedHex, 18 | transformToColorScale, 19 | } from "../src/core/domain/transformations"; 20 | import { ColorScale, type ModFactor } from "../src/core/domain/types"; 21 | 22 | describe("flatten function", () => { 23 | test("should return an empty array when input is empty", () => { 24 | const input: ColorScale[] = []; 25 | const output: FlattenedColorScale[] = []; 26 | expect(flatten(input)).toEqual(output); 27 | }); 28 | 29 | test("should return an array with one FlattenedColorScale object when input contains one ColorScale object with one shade", () => { 30 | const input: ColorScale[] = [ 31 | { 32 | colorName: "red", 33 | order: 1, 34 | shades: [{ hexCode: "#ff0000", weight: 1 }], 35 | }, 36 | ]; 37 | const output: FlattenedColorScale[] = [ 38 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 39 | ]; 40 | expect(flatten(input)).toEqual(output); 41 | }); 42 | 43 | test("should return an array with multiple FlattenedColorScale objects when input contains one ColorScale object with multiple shades", () => { 44 | const input: ColorScale[] = [ 45 | { 46 | colorName: "red", 47 | order: 1, 48 | shades: [ 49 | { hexCode: "#ff0000", weight: 1 }, 50 | { hexCode: "#ff7f7f", weight: 0.5 }, 51 | ], 52 | }, 53 | ]; 54 | const output: FlattenedColorScale[] = [ 55 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 56 | { colorName: "red", order: 1, hexCode: "#ff7f7f", weight: 0.5 }, 57 | ]; 58 | expect(flatten(input)).toEqual(output); 59 | }); 60 | 61 | test("should return an array with FlattenedColorScale objects corresponding to each shade of each ColorScale object when input contains multiple ColorScale objects with multiple shades", () => { 62 | const input: ColorScale[] = [ 63 | { 64 | colorName: "red", 65 | order: 1, 66 | shades: [ 67 | { hexCode: "#ff0000", weight: 1 }, 68 | { hexCode: "#ff7f7f", weight: 0.5 }, 69 | ], 70 | }, 71 | { 72 | colorName: "blue", 73 | order: 2, 74 | shades: [ 75 | { hexCode: "#0000ff", weight: 1 }, 76 | { hexCode: "#7f7fff", weight: 0.5 }, 77 | ], 78 | }, 79 | ]; 80 | const output: FlattenedColorScale[] = [ 81 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 82 | { colorName: "red", order: 1, hexCode: "#ff7f7f", weight: 0.5 }, 83 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 84 | { colorName: "blue", order: 2, hexCode: "#7f7fff", weight: 0.5 }, 85 | ]; 86 | expect(flatten(input)).toEqual(output); 87 | }); 88 | }); 89 | 90 | describe("getDistinctWeights", () => { 91 | it("should extract distinct weights from an array of FlattenedColorScale objects", () => { 92 | const data: FlattenedColorScale[] = [ 93 | { colorName: "blue", hexCode: "", order: 1, weight: 100 }, 94 | { colorName: "blue", hexCode: "", order: 1, weight: 200 }, 95 | { colorName: "blue", hexCode: "", order: 1, weight: 300 }, 96 | { colorName: "red", hexCode: "", order: 2, weight: 100 }, 97 | { colorName: "red", hexCode: "", order: 2, weight: 200 }, 98 | { colorName: "red", hexCode: "", order: 2, weight: 300 }, 99 | { colorName: "red", hexCode: "", order: 2, weight: 400 }, 100 | ]; 101 | 102 | const result = getDistinctWeights(data); 103 | 104 | const expectedResult = [100, 200, 300, 400]; 105 | 106 | expect(result).toEqual(expectedResult); 107 | }); 108 | 109 | it("should return an empty array when input data is empty", () => { 110 | const data: FlattenedColorScale[] = []; 111 | 112 | const result = getDistinctWeights(data); 113 | 114 | // Expected result is an empty array. 115 | const expectedResult: number[] = []; 116 | 117 | expect(result).toEqual(expectedResult); 118 | }); 119 | }); 120 | 121 | describe("filterByWeight function", () => { 122 | test("should return an empty array when input is empty", () => { 123 | const input: FlattenedColorScale[] = []; 124 | const output: FlattenedColorScale[] = []; 125 | expect(filterByWeight(input, 1)).toEqual(output); 126 | }); 127 | 128 | test("should return an array with one FlattenedColorScale object when input contains one object with the specified weight", () => { 129 | const input: FlattenedColorScale[] = [ 130 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 131 | ]; 132 | const output: FlattenedColorScale[] = [ 133 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 134 | ]; 135 | expect(filterByWeight(input, 1)).toEqual(output); 136 | }); 137 | 138 | test("should return an array with multiple FlattenedColorScale objects when input contains multiple objects with the specified weight", () => { 139 | const input: FlattenedColorScale[] = [ 140 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 141 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 142 | ]; 143 | const output: FlattenedColorScale[] = [ 144 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 145 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 146 | ]; 147 | expect(filterByWeight(input, 1)).toEqual(output); 148 | }); 149 | 150 | test("should return an empty array when input contains FlattenedColorScale objects but none with the specified weight", () => { 151 | const input: FlattenedColorScale[] = [ 152 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 0.5 }, 153 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 0.5 }, 154 | ]; 155 | const output: FlattenedColorScale[] = []; 156 | expect(filterByWeight(input, 1)).toEqual(output); 157 | }); 158 | test("should return an array with only the FlattenedColorScale objects that match the specified weight", () => { 159 | const input: FlattenedColorScale[] = [ 160 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 161 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 162 | { colorName: "green", order: 3, hexCode: "#00ff00", weight: 0.5 }, 163 | ]; 164 | const output: FlattenedColorScale[] = [ 165 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 166 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 167 | ]; 168 | expect(filterByWeight(input, 1)).toEqual(output); 169 | }); 170 | }); 171 | 172 | describe("addHue function", () => { 173 | test("should return an empty array when input is empty", () => { 174 | const input: FlattenedColorScale[] = []; 175 | const output: WithHue[] = []; 176 | expect(addHue(input)).toEqual(output); 177 | }); 178 | 179 | test("should return an array with one AddedHue object when input contains one FlattenedColorScale object", () => { 180 | const input: FlattenedColorScale[] = [ 181 | { colorName: "blue", order: 1, hexCode: "#0000ff", weight: 1 }, 182 | ]; 183 | const output: WithHue[] = [ 184 | { colorName: "blue", order: 1, hexCode: "#0000ff", weight: 1, hue: 240 }, 185 | ]; 186 | expect(addHue(input)).toEqual(output); 187 | }); 188 | 189 | test("should return an array with multiple AddedHue objects when input contains multiple FlattenedColorScale objects", () => { 190 | const input: FlattenedColorScale[] = [ 191 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1 }, 192 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1 }, 193 | ]; 194 | const output: WithHue[] = [ 195 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1, hue: 0 }, 196 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1, hue: 240 }, 197 | ]; 198 | expect(addHue(input)).toEqual(output); 199 | }); 200 | }); 201 | 202 | describe("validateAllWeightsAreEqual function", () => { 203 | test("should not throw an error when all weights are equal", () => { 204 | const input: WithHue[] = [ 205 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1, hue: 0 }, 206 | { colorName: "blue", order: 2, hexCode: "#0000ff", weight: 1, hue: 240 }, 207 | ]; 208 | expect(() => validateAllWeightsAreEqual(input)).not.toThrow(); 209 | }); 210 | 211 | test("should throw an error when weights are not equal", () => { 212 | const input: WithHue[] = [ 213 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1, hue: 0 }, 214 | { 215 | colorName: "blue", 216 | order: 2, 217 | hexCode: "#0000ff", 218 | weight: 0.5, 219 | hue: 240, 220 | }, 221 | ]; 222 | expect(() => validateAllWeightsAreEqual(input)).toThrowError( 223 | "weights are not equal" 224 | ); 225 | }); 226 | 227 | test("should not throw an error when input is empty", () => { 228 | const input: WithHue[] = []; 229 | expect(() => validateAllWeightsAreEqual(input)).not.toThrow(); 230 | }); 231 | }); 232 | 233 | describe("sortByHue function", () => { 234 | test("should sort an array of WithHue objects by hue in ascending order", () => { 235 | const input: WithHue[] = [ 236 | { colorName: "blue", order: 1, hexCode: "#0000ff", weight: 1, hue: 240 }, 237 | { colorName: "red", order: 2, hexCode: "#ff0000", weight: 1, hue: 0 }, 238 | { colorName: "green", order: 2, hexCode: "#00ff00", weight: 1, hue: 120 }, 239 | ]; 240 | const output: WithHue[] = [ 241 | { colorName: "red", order: 2, hexCode: "#ff0000", weight: 1, hue: 0 }, 242 | { colorName: "green", order: 2, hexCode: "#00ff00", weight: 1, hue: 120 }, 243 | { colorName: "blue", order: 1, hexCode: "#0000ff", weight: 1, hue: 240 }, 244 | ]; 245 | expect(sortByHue(input)).toEqual(output); 246 | }); 247 | 248 | test("should throw an error when weights are not equal", () => { 249 | const input: WithHue[] = [ 250 | { colorName: "red", order: 1, hexCode: "#ff0000", weight: 1, hue: 0 }, 251 | { 252 | colorName: "blue", 253 | order: 2, 254 | hexCode: "#0000ff", 255 | weight: 0.5, 256 | hue: 240, 257 | }, 258 | ]; 259 | expect(() => sortByHue(input)).toThrowError(); 260 | }); 261 | 262 | test("should return an empty array when input is empty", () => { 263 | const input: WithHue[] = []; 264 | const output: WithHue[] = []; 265 | expect(sortByHue(input)).toEqual(output); 266 | }); 267 | }); 268 | 269 | describe("validateIsSortedByHue", () => { 270 | test("should not throw an error if array is sorted by hue in ascending order", () => { 271 | const data: WithHue[] = [ 272 | { colorName: "red", order: 1, weight: 1, hexCode: "#ff0000", hue: 0 }, 273 | { colorName: "yellow", order: 2, weight: 1, hexCode: "#ffff00", hue: 60 }, 274 | { colorName: "green", order: 3, weight: 1, hexCode: "#008000", hue: 120 }, 275 | ]; 276 | expect(() => validateIsSortedByHue(data)).not.toThrow(); 277 | }); 278 | 279 | test("should throw an error if array is not sorted by hue in ascending order", () => { 280 | const data: WithHue[] = [ 281 | { colorName: "red", order: 1, weight: 1, hexCode: "#ff0000", hue: 0 }, 282 | { colorName: "green", order: 2, weight: 1, hexCode: "#008000", hue: 120 }, 283 | { colorName: "yellow", order: 3, weight: 1, hexCode: "#ffff00", hue: 60 }, 284 | ]; 285 | expect(() => validateIsSortedByHue(data)).toThrow(); 286 | }); 287 | }); 288 | 289 | describe("addAdjancentHues", () => { 290 | test("should add adjacent hues correctly", () => { 291 | const data: WithHue[] = [ 292 | { colorName: "red", order: 1, weight: 1, hexCode: "#ff0000", hue: 0 }, 293 | { colorName: "yellow", order: 2, weight: 1, hexCode: "#ffff00", hue: 60 }, 294 | { colorName: "green", order: 3, weight: 1, hexCode: "#008000", hue: 120 }, 295 | ]; 296 | const result = addAdjancentHues(data); 297 | 298 | const expected: WithAdjancentHues[] = [ 299 | { 300 | colorName: "red", 301 | order: 1, 302 | weight: 1, 303 | hexCode: "#ff0000", 304 | hue: 0, 305 | prevHue: 120, 306 | nextHue: 60, 307 | }, 308 | { 309 | colorName: "yellow", 310 | weight: 1, 311 | order: 2, 312 | hexCode: "#ffff00", 313 | hue: 60, 314 | prevHue: 0, 315 | nextHue: 120, 316 | }, 317 | { 318 | colorName: "green", 319 | weight: 1, 320 | order: 3, 321 | hexCode: "#008000", 322 | hue: 120, 323 | prevHue: 60, 324 | nextHue: 0, 325 | }, 326 | ]; 327 | expect(result).toEqual(expected); 328 | }); 329 | it("should be ok", () => { 330 | const data: WithHue[] = [ 331 | { 332 | colorName: "blue", 333 | order: 16, 334 | hexCode: "#2563eb", 335 | weight: 600, 336 | hue: 221.21212121212122, 337 | }, 338 | { 339 | colorName: "indigo", 340 | order: 17, 341 | hexCode: "#4f46e5", 342 | weight: 600, 343 | hue: 243.39622641509433, 344 | }, 345 | { 346 | colorName: "violet", 347 | order: 18, 348 | hexCode: "#7c3aed", 349 | weight: 600, 350 | hue: 262.1229050279329, 351 | }, 352 | ]; 353 | const result = addAdjancentHues(data); 354 | 355 | const expected: WithAdjancentHues[] = [ 356 | { 357 | colorName: "blue", 358 | order: 16, 359 | hexCode: "#2563eb", 360 | weight: 600, 361 | hue: 221.21212121212122, 362 | prevHue: 262.1229050279329, 363 | nextHue: 243.39622641509433, 364 | }, 365 | { 366 | colorName: "indigo", 367 | order: 17, 368 | hexCode: "#4f46e5", 369 | weight: 600, 370 | hue: 243.39622641509433, 371 | prevHue: 221.21212121212122, 372 | nextHue: 262.1229050279329, 373 | }, 374 | { 375 | colorName: "violet", 376 | order: 18, 377 | hexCode: "#7c3aed", 378 | weight: 600, 379 | hue: 262.1229050279329, 380 | prevHue: 243.39622641509433, 381 | nextHue: 221.21212121212122, 382 | }, 383 | ]; 384 | expect(result).toEqual(expected); 385 | }); 386 | }); 387 | 388 | describe("addHueRanges", () => { 389 | it("should add hue ranges to the flattened palette with adjacent hues", () => { 390 | const data: WithAdjancentHues[] = [ 391 | { 392 | colorName: "red", 393 | order: 1, 394 | weight: 1, 395 | hexCode: "#ff0000", 396 | hue: 0, 397 | prevHue: 120, 398 | nextHue: 60, 399 | }, 400 | { 401 | colorName: "yellow", 402 | weight: 1, 403 | order: 2, 404 | hexCode: "#ffff00", 405 | hue: 60, 406 | prevHue: 0, 407 | nextHue: 120, 408 | }, 409 | { 410 | colorName: "green", 411 | weight: 1, 412 | order: 3, 413 | hexCode: "#008000", 414 | hue: 120, 415 | prevHue: 60, 416 | nextHue: 0, 417 | }, 418 | ]; 419 | 420 | const result = addHueRanges(data); 421 | 422 | // Expected result is based on the behavior of the addHueRanges function. 423 | // This might need to be updated if the function's behavior changes. 424 | const expectedResult: WithHueRange[] = [ 425 | { 426 | colorName: "red", 427 | order: 1, 428 | weight: 1, 429 | hexCode: "#ff0000", 430 | hue: 0, 431 | prevHue: 120, 432 | nextHue: 60, 433 | minHue: 240, 434 | maxHue: 30, 435 | }, 436 | { 437 | colorName: "yellow", 438 | weight: 1, 439 | order: 2, 440 | hexCode: "#ffff00", 441 | hue: 60, 442 | prevHue: 0, 443 | nextHue: 120, 444 | minHue: 30, 445 | maxHue: 90, 446 | }, 447 | { 448 | colorName: "green", 449 | weight: 1, 450 | order: 3, 451 | hexCode: "#008000", 452 | hue: 120, 453 | prevHue: 60, 454 | nextHue: 0, 455 | minHue: 90, 456 | maxHue: 240, 457 | }, 458 | ]; 459 | 460 | expect(result).toEqual(expectedResult); 461 | }); 462 | 463 | it("should handle an empty input array", () => { 464 | const data: WithAdjancentHues[] = []; 465 | 466 | const result = addHueRanges(data); 467 | 468 | // Expected result is an empty array. 469 | const expectedResult: WithAdjancentHues[] = []; 470 | 471 | expect(result).toEqual(expectedResult); 472 | }); 473 | }); 474 | 475 | describe("addModifiedHex", () => { 476 | it("should add modified hex code to the flattened palette with hue ranges", () => { 477 | const data: WithHueRange[] = [ 478 | { 479 | colorName: "red", 480 | order: 1, 481 | weight: 1, 482 | hexCode: "#bf4040", 483 | hue: 0, 484 | prevHue: 120, 485 | nextHue: 60, 486 | minHue: 240, 487 | maxHue: 30, 488 | }, 489 | { 490 | colorName: "yellow", 491 | weight: 1, 492 | order: 2, 493 | hexCode: "#bfbf40", 494 | hue: 60, 495 | prevHue: 0, 496 | nextHue: 120, 497 | minHue: 30, 498 | maxHue: 90, 499 | }, 500 | { 501 | colorName: "green", 502 | weight: 1, 503 | order: 3, 504 | hexCode: "#40bf40", 505 | hue: 120, 506 | prevHue: 60, 507 | nextHue: 0, 508 | minHue: 90, 509 | maxHue: 240, 510 | }, 511 | ]; 512 | const modFactor: ModFactor = { 513 | hueMod: 0.5, 514 | satMod: 0.5, 515 | }; 516 | 517 | const result = addModifiedHex(data, modFactor); 518 | 519 | const expected: WithModifiedHex[] = [ 520 | { 521 | colorName: "red", 522 | order: 1, 523 | weight: 1, 524 | hexCode: "#bf4040", 525 | hue: 0, 526 | prevHue: 120, 527 | nextHue: 60, 528 | minHue: 240, 529 | maxHue: 30, 530 | modifiedHex: "#df5020", 531 | }, 532 | { 533 | colorName: "yellow", 534 | weight: 1, 535 | order: 2, 536 | hexCode: "#bfbf40", 537 | hue: 60, 538 | prevHue: 0, 539 | nextHue: 120, 540 | minHue: 30, 541 | maxHue: 90, 542 | modifiedHex: "#afdf20", 543 | }, 544 | { 545 | colorName: "green", 546 | weight: 1, 547 | order: 3, 548 | hexCode: "#40bf40", 549 | hue: 120, 550 | prevHue: 60, 551 | nextHue: 0, 552 | minHue: 90, 553 | maxHue: 240, 554 | modifiedHex: "#20dfdf", 555 | }, 556 | ]; 557 | 558 | expect(result).toEqual(expected); 559 | }); 560 | 561 | it("should handle an empty input array", () => { 562 | const data: WithHueRange[] = []; 563 | const modFactor: ModFactor = { 564 | hueMod: 0.5, 565 | satMod: 0.5, 566 | }; 567 | 568 | const result = addModifiedHex(data, modFactor); 569 | 570 | // Expected result is an empty array. 571 | const expectedResult: WithHueRange[] = []; 572 | 573 | expect(result).toEqual(expectedResult); 574 | }); 575 | }); 576 | 577 | describe("transformToColorScale", () => { 578 | it("should transform data to color scale", () => { 579 | const data: WithModifiedHex[] = [ 580 | { 581 | colorName: "red", 582 | order: 1, 583 | weight: 1, 584 | hexCode: "#bf4040", 585 | hue: 0, 586 | prevHue: 120, 587 | nextHue: 60, 588 | minHue: 240, 589 | maxHue: 30, 590 | modifiedHex: "#df5020", 591 | }, 592 | { 593 | colorName: "red", 594 | order: 1, 595 | weight: 2, 596 | hexCode: "#bf4040", 597 | hue: 0, 598 | prevHue: 120, 599 | nextHue: 60, 600 | minHue: 240, 601 | maxHue: 30, 602 | modifiedHex: "#red2hex", 603 | }, 604 | { 605 | colorName: "yellow", 606 | weight: 1, 607 | order: 2, 608 | hexCode: "#bfbf40", 609 | hue: 60, 610 | prevHue: 0, 611 | nextHue: 120, 612 | minHue: 30, 613 | maxHue: 90, 614 | modifiedHex: "#afdf20", 615 | }, 616 | { 617 | colorName: "green", 618 | weight: 1, 619 | order: 3, 620 | hexCode: "#40bf40", 621 | hue: 120, 622 | prevHue: 60, 623 | nextHue: 0, 624 | minHue: 90, 625 | maxHue: 240, 626 | modifiedHex: "#20dfdf", 627 | }, 628 | ]; 629 | 630 | const result = transformToColorScale(data); 631 | 632 | const expectedResult: ColorScale[] = [ 633 | { 634 | _tag: "ChromaticColorScale", 635 | colorName: "red", 636 | order: 1, 637 | shades: [ 638 | { weight: 1, hexCode: "#df5020" }, 639 | { weight: 2, hexCode: "#red2hex" }, 640 | ], 641 | }, 642 | { 643 | _tag: "ChromaticColorScale", 644 | colorName: "yellow", 645 | order: 2, 646 | shades: [{ weight: 1, hexCode: "#afdf20" }], 647 | }, 648 | { 649 | _tag: "ChromaticColorScale", 650 | colorName: "green", 651 | order: 3, 652 | shades: [{ weight: 1, hexCode: "#20dfdf" }], 653 | }, 654 | ]; 655 | 656 | expect(result).toEqual(expectedResult); 657 | }); 658 | 659 | it("should handle an empty input array", () => { 660 | const data: WithModifiedHex[] = []; 661 | 662 | const result = transformToColorScale(data); 663 | 664 | // Expected result is an empty array. 665 | const expectedResult: ColorScale[] = []; 666 | 667 | expect(result).toEqual(expectedResult); 668 | }); 669 | }); 670 | --------------------------------------------------------------------------------