Skip to content

Commit

Permalink
Added color blending
Browse files Browse the repository at this point in the history
  • Loading branch information
hollandjake committed Jan 12, 2024
1 parent b6021e6 commit e1c31d5
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 9 deletions.
22 changes: 21 additions & 1 deletion src/colorspaces/color.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ColorSpace, Format} from '../types';
import {BlendMode, BlendModeColorSpaceMap, ColorSpace, Format} from '../types';
import {clamp01} from '../utils';

export abstract class Color {
Expand Down Expand Up @@ -27,4 +27,24 @@ export abstract class Color {
* @param format An optional format type to force a format
*/
abstract toString(format?: Format): string;

/**
* Linearly interpolate between this and another color by an amount following a particular blending algorithm
*
* If the colorspace of both colors don't match, the `other` color will be mapped to the colorspace of `this` color
*
* @param other - The other {@link Color} to interpolate with
* @param f - The interpolation factor
* @param blendMode - Optional blending algorithm to use, adjusting the colorspace if necessary,
* both colors will be aligned
*/
blend(other: Color, f: number, blendMode?: BlendMode): Color {
const colorspace = blendMode ? BlendModeColorSpaceMap[blendMode] : this.colorspace;
if (colorspace === undefined) throw new Error(`Unknown colorspace '${colorspace}'`);

// If colors are already in the target colorspace then it's a no-op
return this.mapTo(colorspace)._blend(other.mapTo(colorspace), f, blendMode ?? colorspace);
}

protected abstract _blend(other: Color, f: number, blendMode: BlendMode): Color;
}
44 changes: 44 additions & 0 deletions src/colorspaces/srgb/blend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {describe, expect, test} from 'vitest';
import {SRGB, rgb255} from '.';
import {hslBlend, hwbBlend, rgbBlend} from './blend';

const COL_A = rgb255(39, 0, 214);
const COL_B = rgb255(247, 148, 89);

describe('rgbBlend', () => {
test.each([
[new SRGB(0, 0, 0, 0), new SRGB(1, 1, 1, 1), 0.5, new SRGB(0.5, 0.5, 0.5, 0.5)],
[new SRGB(1, 1, 1, 1), new SRGB(0, 0, 0, 0), 0.5, new SRGB(0.5, 0.5, 0.5, 0.5)],
[new SRGB(0, 0, 0, 0), new SRGB(1, 1, 1, 1), 0.25, new SRGB(0.25, 0.25, 0.25, 0.25)],
[COL_A, COL_B, 0, COL_A],
[COL_A, COL_B, 0.25, rgb255(91, 37, 183, 1)],
[COL_A, COL_B, 0.5, rgb255(143, 74, 152)],
[COL_A, COL_B, 0.75, rgb255(195, 111, 120)],
[COL_A, COL_B, 1, COL_B],
] as [SRGB, SRGB, number, SRGB][])('rgbBlend(%s, %s, %d) -> %s', (a, b, f, expected) => {
expect(rgbBlend(a, b, f).toString('hex')).toEqual(expected.toString('hex'));
});
});

describe('hslBlend', () => {
test.each([
[COL_A, COL_B, 0, COL_A],
[COL_A, COL_B, 0.25, rgb255(3, 187, 242)],
[COL_A, COL_B, 0.5, rgb255(25, 250, 88)],
[COL_A, COL_B, 0.75, rgb255(186, 248, 58)],
[COL_A, COL_B, 1, COL_B],
] as [SRGB, SRGB, number, SRGB][])('hslBlend(%s, %s, %d) -> %s', (a, b, f, expected) => {
expect(hslBlend(a, b, f).toString('hex')).toEqual(expected.toString('hex'));
});
});
describe('blendMode - HWB', () => {
test.each([
[COL_A, COL_B, 0, COL_A],
[COL_A, COL_B, 0.25, rgb255(22, 176, 222)],
[COL_A, COL_B, 0.5, rgb255(45, 230, 96)],
[COL_A, COL_B, 0.75, rgb255(183, 239, 67)],
[COL_A, COL_B, 1, COL_B],
] as [SRGB, SRGB, number, SRGB][])('hwbBlend(%s, %s, %d) -> %s', (a, b, f, expected) => {
expect(hwbBlend(a, b, f).toString('hex')).toEqual(expected.toString('hex'));
});
});
39 changes: 39 additions & 0 deletions src/colorspaces/srgb/blend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {SRGB} from './srgb';
import {hslToSRGB, hwbToSRGB, SRGBToHsl, SRGBToHwb} from './utils';

export function rgbBlend(colorA: SRGB, colorB: SRGB, f: number): SRGB {
return new SRGB(
colorA.r + f * (colorB.r - colorA.r),
colorA.g + f * (colorB.g - colorA.g),
colorA.b + f * (colorB.b - colorA.b),
colorA.a + f * (colorB.a - colorA.a)
);
}

export function hslBlend(colorA: SRGB, colorB: SRGB, f: number): SRGB {
const colorAHSL = SRGBToHsl(colorA.r, colorA.g, colorA.b);
const colorBHSL = SRGBToHsl(colorB.r, colorB.g, colorB.b);

return new SRGB(
...hslToSRGB(
colorAHSL[0] + f * (colorBHSL[0] - colorAHSL[0]),
colorAHSL[1] + f * (colorBHSL[1] - colorAHSL[1]),
colorAHSL[2] + f * (colorBHSL[2] - colorAHSL[2])
),
colorA.a + f * (colorB.a - colorA.a)
);
}

export function hwbBlend(colorA: SRGB, colorB: SRGB, f: number): SRGB {
const colorAHWB = SRGBToHwb(colorA.r, colorA.g, colorA.b);
const colorBHWB = SRGBToHwb(colorB.r, colorB.g, colorB.b);

return new SRGB(
...hwbToSRGB(
colorAHWB[0] + f * (colorBHWB[0] - colorAHWB[0]),
colorAHWB[1] + f * (colorBHWB[1] - colorAHWB[1]),
colorAHWB[2] + f * (colorBHWB[2] - colorAHWB[2])
),
colorA.a + f * (colorB.a - colorA.a)
);
}
25 changes: 24 additions & 1 deletion src/colorspaces/srgb/srgb.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {describe, expect, test} from 'vitest';
import {ColorSpace, Format} from '../../types';
import {BlendMode, ColorSpace, Format, SRGBBlendMode} from '../../types';
import {Color} from '../color';
import {rgb255} from './builder';
import {SRGB} from './srgb';

describe('SRGB', () => {
Expand Down Expand Up @@ -40,4 +41,26 @@ describe('SRGB', () => {
expect(() => new SRGB(0.1, 0.2, 0.3).toString('invalid' as Format)).toThrow();
});
});

describe('blend', () => {
const COL_A = rgb255(39, 0, 214);
const COL_B = rgb255(247, 148, 89);
test.each([
[COL_A, COL_B, 0.5, undefined, rgb255(143, 74, 152)],
[COL_A, COL_B, 0.5, 'sRGB', rgb255(143, 74, 152)],
[COL_A, COL_B, 0.5, 'rgb', rgb255(143, 74, 152)],
[COL_A, COL_B, 0.5, 'hex', rgb255(143, 74, 152)],
[COL_A, COL_B, 0.5, 'hsl', rgb255(25, 250, 88)],
[COL_A, COL_B, 0.5, 'hwb', rgb255(45, 230, 96)],
] as [Color, Color, number, BlendMode | undefined, Color][])(
'%s.blend(%s, %d, %s) -> %s',
(a, b, f, blendMode, expected) => {
expect(a.blend(b, f, blendMode).toString('rgb')).toEqual(expected.toString('rgb'));
}
);

test('invalid', () => {
expect(() => COL_A.blend(COL_B, 0.5, 'invalid' as SRGBBlendMode)).toThrow();
});
});
});
16 changes: 15 additions & 1 deletion src/colorspaces/srgb/srgb.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ColorSpace, Format} from '../../types';
import {ColorSpace, Format, SRGBBlendMode} from '../../types';
import {clamp01} from '../../utils';
import {Color} from '../color';
import {hslBlend, hwbBlend, rgbBlend} from './blend';
import {SRGBToHEXString, SRGBToHSLString, SRGBToHWBString, SRGBToRGBString, SRGBToString} from './stringify';

/**
Expand Down Expand Up @@ -49,4 +50,17 @@ export class SRGB extends Color {
throw new Error(`Invalid format option '${format}'`);
}
}

protected _blend(other: SRGB, f: number, blendMode: SRGBBlendMode): SRGB {
switch (blendMode) {
case 'sRGB':
case 'rgb':
case 'hex':
return rgbBlend(this, other, f);
case 'hsl':
return hslBlend(this, other, f);
case 'hwb':
return hwbBlend(this, other, f);
}
}
}
12 changes: 12 additions & 0 deletions src/types/blend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {SRGBBlendMode} from './colorspaces';
import {ColorSpace} from './css';

export type BlendMode = SRGBBlendMode;

export const BlendModeColorSpaceMap: {[key in BlendMode]: ColorSpace} = {
sRGB: 'sRGB',
hex: 'sRGB',
rgb: 'sRGB',
hsl: 'sRGB',
hwb: 'sRGB',
};
1 change: 1 addition & 0 deletions src/types/colorspaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './srgb';
6 changes: 3 additions & 3 deletions src/types/srgb.ts → src/types/colorspaces/srgb.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* The types as defined in the CSS color spec https://www.w3.org/TR/css-color-4
*/
import {AlphaValue, AlphaValueOrNone, Hue, HueOrNone, NumberOrNone, OWS, PercentageOrNone, WS} from './css';
import {Percentage} from './percentage';
import {AlphaValue, AlphaValueOrNone, Hue, HueOrNone, NumberOrNone, OWS, PercentageOrNone, WS} from '../css';
import {Percentage} from '../percentage';

export type SRGBFormat = 'sRGB' | 'hex' | 'rgb' | 'hsl' | 'hwb';
export type SRGBBlendMode = 'sRGB' | 'hex' | 'rgb' | 'hsl' | 'hwb';

export type rgb = rgb_color4 | rgb_legacy;
type rgb_color4 =
Expand Down
4 changes: 2 additions & 2 deletions src/types/format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {SRGBFormat} from './srgb';
import {SRGBBlendMode} from './colorspaces';

export type Format = SRGBFormat;
export type Format = SRGBBlendMode;
3 changes: 2 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './angle';
export * from './blend';
export * from './colorspaces';
export {CSSColor, ColorSpace} from './css';
export * from './format';
export * from './percentage';
export * from './srgb';

0 comments on commit e1c31d5

Please sign in to comment.