From 7253355095dfdfe191332700f73aaa747ccfd569 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Tue, 10 Dec 2024 21:38:58 +0100 Subject: [PATCH] add range node --- .changeset/tiny-monkeys-jam.md | 5 + .../graph-engine/src/nodes/color/index.ts | 2 + .../graph-engine/src/nodes/color/range.ts | 149 +++++++++++++++++ .../tests/suites/nodes/color/range.test.ts | 157 ++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 .changeset/tiny-monkeys-jam.md create mode 100644 packages/graph-engine/src/nodes/color/range.ts create mode 100644 packages/graph-engine/tests/suites/nodes/color/range.test.ts diff --git a/.changeset/tiny-monkeys-jam.md b/.changeset/tiny-monkeys-jam.md new file mode 100644 index 000000000..df4ca0196 --- /dev/null +++ b/.changeset/tiny-monkeys-jam.md @@ -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. diff --git a/packages/graph-engine/src/nodes/color/index.ts b/packages/graph-engine/src/nodes/color/index.ts index 10b97df51..12fed445d 100644 --- a/packages/graph-engine/src/nodes/color/index.ts +++ b/packages/graph-engine/src/nodes/color/index.ts @@ -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'; @@ -32,6 +33,7 @@ export const nodes = [ matchAlpha, name, poline, + range, scale, wheel, mix, diff --git a/packages/graph-engine/src/nodes/color/range.ts b/packages/graph-engine/src/nodes/color/range.ts new file mode 100644 index 000000000..cc1991cbb --- /dev/null +++ b/packages/graph-engine/src/nodes/color/range.ts @@ -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 { + 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); + } +} diff --git a/packages/graph-engine/tests/suites/nodes/color/range.test.ts b/packages/graph-engine/tests/suites/nodes/color/range.test.ts new file mode 100644 index 000000000..9b2f8fc94 --- /dev/null +++ b/packages/graph-engine/tests/suites/nodes/color/range.test.ts @@ -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); + }); +});