├── 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 |
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 |
--------------------------------------------------------------------------------