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

Add color range node #579

Merged
merged 1 commit into from
Dec 12, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tiny-monkeys-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tokens-studio/graph-engine": minor
---

Add Color Range node that lets you create a gradient between two colors and sample steps on it.
2 changes: 2 additions & 0 deletions packages/graph-engine/src/nodes/color/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import matchAlpha from './matchAlpha.js';
import mix from './mix.js';
import name from './name.js';
import poline from './poline.js';
import range from './range.js';
import scale from './scale.js';
import sortByDistance from './sortByDistance.js';
import stringToCol from './stringToColor.js';
Expand All @@ -32,6 +33,7 @@ export const nodes = [
matchAlpha,
name,
poline,
range,
scale,
wheel,
mix,
Expand Down
149 changes: 149 additions & 0 deletions packages/graph-engine/src/nodes/color/range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Black, White, toColor, toColorObject } from './lib/utils.js';
import {
ColorSchema,
NumberSchema,
StringSchema
} from '../../schemas/index.js';
import { ColorSpaces } from './lib/spaces.js';
import { Color as ColorType } from '../../types.js';
import { INodeDefinition, ToInput, ToOutput } from '../../index.js';
import { Node } from '../../programmatic/node.js';
import { arrayOf } from '../../schemas/utils.js';
import { setToPrecision } from '@/utils/precision.js';

const HUE_METHODS = [
'shorter',
'longer',
'increasing',
'decreasing',
'raw'
] as const;
const PROGRESSION_TYPES = ['linear', 'quadratic', 'cubic'] as const;

const progressionFunctions = {
linear: (p: number) => p,
quadratic: (p: number) => p * p,
cubic: (p: number) => p * p * p
};

const roundColorChannels = (color: ColorType): ColorType => {
return {
...color,
channels: [
Math.abs(setToPrecision(color.channels[0], 6)),
Math.abs(setToPrecision(color.channels[1], 6)),
Math.abs(setToPrecision(color.channels[2], 6))
] as [number, number, number],
alpha: color.alpha ? setToPrecision(color.alpha, 6) : undefined
};
};

export default class NodeDefinition extends Node {
static title = 'Range';
static type = 'studio.tokens.color.range';
static description =
'Creates a range/gradient between two colors with customizable interpolation options';

declare inputs: ToInput<{
colorA: ColorType;
colorB: ColorType;
space: string;
hue: (typeof HUE_METHODS)[number];
steps: number;
progression: (typeof PROGRESSION_TYPES)[number];
}>;

declare outputs: ToOutput<{
colors: ColorType[];
}>;

constructor(props: INodeDefinition) {
super(props);

this.addInput('colorA', {
type: {
...ColorSchema,
default: White
}
});

this.addInput('colorB', {
type: {
...ColorSchema,
default: Black
}
});

this.addInput('space', {
type: {
...StringSchema,
enum: ColorSpaces,
default: 'lab'
}
});

this.addInput('hue', {
type: {
...StringSchema,
enum: HUE_METHODS,
default: 'shorter'
}
});

this.addInput('steps', {
type: {
...NumberSchema,
default: 5,
minimum: 2
}
});

this.addInput('progression', {
type: {
...StringSchema,
enum: PROGRESSION_TYPES,
default: 'linear'
}
});

this.addOutput('colors', {
type: arrayOf(ColorSchema)
});
}

execute(): void | Promise<void> {
const { colorA, colorB, space, hue, steps, progression } =
this.getAllInputs();

const color1 = toColor(colorA);
const color2 = toColor(colorB);

const range = color1.range(color2, {
space,
hue,
outputSpace: colorA.space
});

const progressionFn = progressionFunctions[progression];
const colors: ColorType[] = [];

for (let i = 0; i < steps; i++) {
const progress = i / (steps - 1);
const adjustedProgress = progressionFn(progress);
const color = range(adjustedProgress);

// Preserve original color spaces for endpoints
const outputSpace =
i === 0 ? colorA.space : i === steps - 1 ? colorB.space : colorA.space;

colors.push(
roundColorChannels({
...toColorObject(color),
space: outputSpace
})
);
}

this.outputs.colors.set(colors);
}
}
157 changes: 157 additions & 0 deletions packages/graph-engine/tests/suites/nodes/color/range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Graph } from '../../../../src/graph/graph.js';
import { describe, expect, test } from 'vitest';
import { toColor } from '../../../../src/nodes/color/lib/utils.js';
import Node from '../../../../src/nodes/color/range.js';

describe('Color Range Node', () => {
const createNode = () => {
const graph = new Graph();
return new Node({ graph });
};

test('should generate correct number of steps', () => {
const node = createNode();

// Test with 3 steps
node.inputs.colorA.setValue({ space: 'srgb', channels: [1, 0, 0] }); // Red
node.inputs.colorB.setValue({ space: 'srgb', channels: [0, 0, 1] }); // Blue
node.inputs.steps.setValue(3);
node.execute();

const colors = node.outputs.colors.value;
expect(colors).toHaveLength(3);

// Test with 5 steps
node.inputs.steps.setValue(5);
node.execute();
expect(node.outputs.colors.value).toHaveLength(5);
});

test('should handle different color spaces correctly', () => {
const node = createNode();
const colorA = { space: 'srgb', channels: [1, 0, 0] }; // Red
const colorB = { space: 'srgb', channels: [0, 0, 1] }; // Blue

node.inputs.colorA.setValue(colorA);
node.inputs.colorB.setValue(colorB);
node.inputs.steps.setValue(3);

// Test different spaces
const spaces = ['lab', 'lch', 'srgb', 'hsl'];
spaces.forEach(space => {
node.inputs.space.setValue(space);
node.execute();

const colors = node.outputs.colors.value;
expect(colors).toHaveLength(3);
expect(colors[0]).toMatchObject(colorA);
expect(colors[2]).toMatchObject(colorB);
});
});

test('should apply progression curves properly', () => {
const node = createNode();

node.inputs.colorA.setValue({ space: 'srgb', channels: [0, 0, 0] }); // Black
node.inputs.colorB.setValue({ space: 'srgb', channels: [1, 1, 1] }); // White
node.inputs.steps.setValue(3);

// Test linear progression
node.inputs.progression.setValue('linear');
node.execute();
const linearColors = node.outputs.colors.value;

// Test quadratic progression
node.inputs.progression.setValue('quadratic');
node.execute();
const quadraticColors = node.outputs.colors.value;

// Middle color should be darker in quadratic progression
const linearMiddle = toColor(linearColors[1]);
const quadraticMiddle = toColor(quadraticColors[1]);
expect(linearMiddle.oklch.l).toBeGreaterThan(quadraticMiddle.oklch.l);
});

test('should handle hue interpolation methods correctly', () => {
const node = createNode();

// Use colors with distinctly different hues
node.inputs.colorA.setValue({ space: 'hsl', channels: [0, 100, 50] }); // Red
node.inputs.colorB.setValue({ space: 'hsl', channels: [240, 100, 50] }); // Blue
node.inputs.steps.setValue(3);
node.inputs.space.setValue('hsl');

// Test different hue methods
const hueMethods = ['shorter', 'longer', 'increasing', 'decreasing'];
const results = hueMethods.map(method => {
node.inputs.hue.setValue(method);
node.execute();
return node.outputs.colors.value[1].channels[0]; // Get middle color's hue
});

// Verify that different hue methods produce different results
const uniqueHues = new Set(results);
expect(uniqueHues.size).toBeGreaterThan(1);
});

test('should maintain alpha values', () => {
const node = createNode();

// Test with transparent colors
node.inputs.colorA.setValue({
space: 'srgb',
channels: [1, 0, 0],
alpha: 0.5
});
node.inputs.colorB.setValue({
space: 'srgb',
channels: [0, 0, 1],
alpha: 1
});
node.inputs.steps.setValue(3);
node.execute();

const colors = node.outputs.colors.value;
expect(colors[0].alpha).toBe(0.5);
expect(colors[1].alpha).toBe(0.75);
expect(colors[2].alpha).toBe(1);
});

test('should handle edge cases', () => {
const node = createNode();

// Test with same colors
const sameColor = { space: 'srgb', channels: [1, 0, 0] };
node.inputs.colorA.setValue(sameColor);
node.inputs.colorB.setValue(sameColor);
node.inputs.steps.setValue(3);
node.execute();

const colors = node.outputs.colors.value;
expect(colors).toHaveLength(3);
colors.forEach(color => {
expect(color).toMatchObject(sameColor);
});

// Test with minimum steps
node.inputs.steps.setValue(2);
node.execute();
expect(node.outputs.colors.value).toHaveLength(2);
});

test('should preserve color space of input colors', () => {
const node = createNode();

const hslColor = { space: 'hsl', channels: [0, 100, 50] };
const labColor = { space: 'lab', channels: [50, 50, 0] };

node.inputs.colorA.setValue(hslColor);
node.inputs.colorB.setValue(labColor);
node.inputs.steps.setValue(3);
node.execute();

const colors = node.outputs.colors.value;
expect(colors[0].space).toBe(hslColor.space);
expect(colors[2].space).toBe(labColor.space);
});
});
Loading