diff --git a/.changeset/strong-rules-push.md b/.changeset/strong-rules-push.md new file mode 100644 index 00000000..30259670 --- /dev/null +++ b/.changeset/strong-rules-push.md @@ -0,0 +1,12 @@ +--- +"@tokens-studio/graph-engine": minor +--- + +Added new Color Harmonies node that generates harmonious color combinations based on color theory. Supports: +- Analogous colors (±30°) +- Complementary colors (180°) +- Split-complementary colors (±150°) +- Triadic colors (±120°) +- Tetradic colors (90°, 180°, 270°) +- Custom number of colors with pattern repetition +- Preserves saturation, lightness, and alpha values \ No newline at end of file diff --git a/packages/graph-engine/src/nodes/color/harmonies.ts b/packages/graph-engine/src/nodes/color/harmonies.ts new file mode 100644 index 00000000..cee96c75 --- /dev/null +++ b/packages/graph-engine/src/nodes/color/harmonies.ts @@ -0,0 +1,134 @@ +import { + ColorSchema, + NumberSchema, + StringSchema +} from '../../schemas/index.js'; +import { Color as ColorType } from '../../types.js'; +import { INodeDefinition, ToInput, ToOutput } from '../../index.js'; +import { Node } from '../../programmatic/node.js'; +import { Red, toColor, toColorObject } from './lib/utils.js'; +import Color from 'colorjs.io'; + +type HarmonyType = + | 'analogous' + | 'complementary' + | 'splitComplementary' + | 'triadic' + | 'tetradic'; + +const HarmonyTypeSchema = { + ...StringSchema, + title: 'Harmony Type', + enum: [ + 'analogous', + 'complementary', + 'splitComplementary', + 'triadic', + 'tetradic' + ] +}; + +export default class NodeDefinition extends Node { + static title = 'Color Harmonies'; + static type = 'studio.tokens.color.harmonies'; + static description = + 'Generates harmonious color combinations based on color theory'; + + declare inputs: ToInput<{ + color: ColorType; + harmonyType: HarmonyType; + numberOfColors: number; + }>; + + declare outputs: ToOutput<{ + colors: ColorType[]; + }>; + + constructor(props: INodeDefinition) { + super(props); + this.addInput('color', { + type: { + ...ColorSchema, + default: Red + } + }); + this.addInput('harmonyType', { + type: { + ...HarmonyTypeSchema, + default: 'triadic' + } + }); + this.addInput('numberOfColors', { + type: { + ...NumberSchema, + default: 3 + } + }); + this.addOutput('colors', { + type: { + type: 'array', + items: ColorSchema + } + }); + } + + execute(): void | Promise { + const { color, harmonyType, numberOfColors } = this.getAllInputs(); + const colorInstance = toColor(color); + let harmonies: ColorType[] = []; + + // Convert to HSL for easier manipulation + const hslColor = colorInstance.to('hsl'); + const baseHue = hslColor.coords[0]; + const saturation = hslColor.coords[1]; + const lightness = hslColor.coords[2]; + + // Calculate hue shifts based on harmony type + let hueShifts: number[] = []; + switch (harmonyType) { + case 'analogous': + hueShifts = [-30, 30]; + break; + case 'complementary': + hueShifts = [180]; + break; + case 'splitComplementary': + hueShifts = [150, -150]; + break; + case 'triadic': + hueShifts = [120, -120]; + break; + case 'tetradic': + hueShifts = [90, 180, -90]; + break; + } + + // Start with the base color + harmonies.push(color); + + // Generate harmony colors + for (const shift of hueShifts) { + let newHue = (baseHue + shift) % 360; + if (newHue < 0) newHue += 360; + + const newColor = new Color( + 'hsl', + [newHue, saturation, lightness], + color.alpha + ); + harmonies.push(toColorObject(newColor)); + } + + // Ensure we return the requested number of colors + if (harmonies.length > numberOfColors) { + harmonies = harmonies.slice(0, numberOfColors); + } else if (harmonies.length < numberOfColors) { + const originalLength = harmonies.length; + for (let i = originalLength; i < numberOfColors; i++) { + harmonies.push(harmonies[i % originalLength]); + } + } + + this.outputs.colors.set(harmonies); + } +} diff --git a/packages/graph-engine/src/nodes/color/index.ts b/packages/graph-engine/src/nodes/color/index.ts index 12fed445..fd8db556 100644 --- a/packages/graph-engine/src/nodes/color/index.ts +++ b/packages/graph-engine/src/nodes/color/index.ts @@ -9,6 +9,7 @@ import deconstruct from './deconstruct.js'; import deltaE from './deltaE.js'; import distance from './distance.js'; import flattenAlpha from './flattenAlpha.js'; +import harmonies from './harmonies.js'; import lighten from './lighten.js'; import matchAlpha from './matchAlpha.js'; import mix from './mix.js'; @@ -21,25 +22,26 @@ import stringToCol from './stringToColor.js'; import wheel from './wheel.js'; export const nodes = [ + colorToString, contrast, contrasting, contrastingAlpha, - create, convert, + create, + darken, deconstruct, - distance, deltaE, + distance, flattenAlpha, + harmonies, + lighten, matchAlpha, + mix, name, poline, range, scale, - wheel, - mix, - colorToString, - lighten, - darken, sortByDistance, - stringToCol + stringToCol, + wheel ]; diff --git a/packages/graph-engine/tests/suites/nodes/color/harmonies.test.ts b/packages/graph-engine/tests/suites/nodes/color/harmonies.test.ts new file mode 100644 index 00000000..b5229224 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/color/harmonies.test.ts @@ -0,0 +1,149 @@ +import { Color as ColorType } from '../../../../src/types.js'; +import { Graph } from '../../../../src/graph/graph.js'; +import { Red, toColor } from '../../../../src/nodes/color/lib/utils.js'; +import { describe, expect, test } from 'vitest'; +import Node from '../../../../src/nodes/color/harmonies.js'; + +describe('color/harmonies', () => { + test('generates triadic harmony with default settings', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(3); + expect(colors[0]).toEqual(Red); // Base color (default red) + + // Convert to HSL to check hue shifts + const baseHue = 0; // Red is at 0 degrees + const expectedHues = [ + baseHue, + (baseHue + 120) % 360, + (baseHue - 120 + 360) % 360 + ]; + + colors.forEach((color, index) => { + const hslColor = toColor(color).to('hsl'); + expect(Math.round(hslColor.coords[0])).toBe(expectedHues[index]); + }); + }); + + test('generates complementary harmony', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.harmonyType.setValue('complementary'); + node.inputs.numberOfColors.setValue(2); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(2); + + // Check if second color is complement (180 degrees from base) + const secondColor = toColor(colors[1]).to('hsl'); + expect(Math.round(secondColor.coords[0])).toBe(180); // Complement of red + }); + + test('generates split-complementary harmony', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.harmonyType.setValue('splitComplementary'); + node.inputs.numberOfColors.setValue(3); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(3); + + // Check if colors are at expected angles (150 and -150 from base) + const expectedHues = [0, 150, 210]; // For red base color + colors.forEach((color, index) => { + const hslColor = toColor(color).to('hsl'); + expect(Math.round(hslColor.coords[0])).toBe(expectedHues[index]); + }); + }); + + test('generates tetradic harmony', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.harmonyType.setValue('tetradic'); + node.inputs.numberOfColors.setValue(4); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(4); + + // Check if colors are at expected angles (90, 180, 270 from base) + const expectedHues = [0, 90, 180, 270]; // For red base color + colors.forEach((color, index) => { + const hslColor = toColor(color).to('hsl'); + expect(Math.round(hslColor.coords[0])).toBe(expectedHues[index]); + }); + }); + + test('generates analogous harmony', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.harmonyType.setValue('analogous'); + node.inputs.numberOfColors.setValue(3); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(3); + + // Check if colors are at expected angles (-30 and +30 from base) + const expectedHues = [0, 330, 30]; // For red base color + colors.forEach((color, index) => { + const hslColor = toColor(color).to('hsl'); + expect(Math.round(hslColor.coords[0])).toBe(expectedHues[index]); + }); + }); + + test('handles custom number of colors by repeating', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.harmonyType.setValue('triadic'); + node.inputs.numberOfColors.setValue(5); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + expect(colors).toHaveLength(5); + // Should repeat the pattern: base, +120, -120, base, +120 + const expectedHues = [0, 120, 240, 0, 120]; + colors.forEach((color, index) => { + const hslColor = toColor(color).to('hsl'); + expect(Math.round(hslColor.coords[0])).toBe(expectedHues[index]); + }); + }); + + test('preserves saturation and lightness', async () => { + const graph = new Graph(); + const node = new Node({ graph }); + + node.inputs.color.setValue({ + space: 'srgb', + channels: [1, 0, 0], + alpha: 0.5 // Testing alpha preservation + }); + + await node.execute(); + const colors = node.outputs.colors.value as ColorType[]; + + colors.forEach(color => { + const hslColor = toColor(color).to('hsl'); + // Red in HSL has specific saturation and lightness values + expect(Math.round(hslColor.coords[1])).toBe(100); // Full saturation (100%) + expect(Math.round(hslColor.coords[2])).toBe(50); // 50% lightness + expect(color.alpha).toBe(0.5); // Alpha should be preserved + }); + }); +});