Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

color harmonie node #631

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/strong-rules-push.md
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions packages/graph-engine/src/nodes/color/harmonies.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
18 changes: 10 additions & 8 deletions packages/graph-engine/src/nodes/color/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
];
149 changes: 149 additions & 0 deletions packages/graph-engine/tests/suites/nodes/color/harmonies.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});
Loading