diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts index d09db865e..e170e06cd 100644 --- a/src/core/configuration/Colors.ts +++ b/src/core/configuration/Colors.ts @@ -1,6 +1,11 @@ -import { colord, Colord } from "colord"; +import { colord, Colord, extend, LabColor } from "colord"; +import labPlugin from "colord/plugins/lab"; +import lchPlugin from "colord/plugins/lch"; import { ColoredTeams, Team } from "../game/Game"; +import { PseudoRandom } from "../PseudoRandom"; import { simpleHash } from "../Util"; +extend([lchPlugin]); +extend([labPlugin]); export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue @@ -341,18 +346,36 @@ export class ColorAllocator { constructor(colors: Colord[], fallback: Colord[]) { this.availableColors = [...colors]; - this.fallbackColors = [...fallback]; + this.fallbackColors = [...colors, ...fallback]; } assignColor(id: string): Colord { if (this.assigned.has(id)) { return this.assigned.get(id)!; } + if (this.availableColors.length === 0) { this.availableColors = [...this.fallbackColors]; } - const index = 0; - const color = this.availableColors.splice(index, 1)[0]; + + const MIN_DELTA_E = 25; // Minimum Delta E for distinct colors + const assignedColors = Array.from(this.assigned.values()); + const rand = new PseudoRandom(simpleHash(id)); + const shuffledColors = rand.shuffleArray(this.availableColors); + + const selection = selectDistinctColor( + shuffledColors, + assignedColors, + MIN_DELTA_E, + ); + + let selectedIndex = 0; + + if (selection) { + selectedIndex = selection.selectedIndex; + } + + const color = this.availableColors.splice(selectedIndex, 1)[0]; this.assigned.set(id, color); return color; } @@ -382,3 +405,32 @@ export class ColorAllocator { } } } + +// Select a distinct color from the available colors that is sufficiently different from the assigned colors +export function selectDistinctColor( + shuffledColors: Colord[], + assignedColors: Colord[], + minDeltaE: number, +): { selectedIndex: number; selectedColor: Colord } | null { + const shuffled = shuffledColors.map((color, index) => ({ color, index })); + + for (const { color, index } of shuffled) { + const isDistinctEnough = assignedColors.every( + (assigned) => deltaE76(color.toLab(), assigned.toLab()) >= minDeltaE, + ); + if (isDistinctEnough) { + return { selectedIndex: index, selectedColor: color }; + } + } + + return null; +} + +// Calculate Delta E using the CIE76 formula +export function deltaE76(c1: LabColor, c2: LabColor) { + return Math.sqrt( + Math.pow(c1.l - c2.l, 2) + + Math.pow(c1.a - c2.a, 2) + + Math.pow(c1.b - c2.b, 2), + ); +} diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index eb04cea0c..887b11583 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -4,6 +4,7 @@ import { botColor, ColorAllocator, red, + selectDistinctColor, teal, } from "../src/core/configuration/Colors"; import { ColoredTeams } from "../src/core/game/Game"; @@ -19,6 +20,8 @@ const fallbackMockColors: Colord[] = [ colord({ r: 255, g: 255, b: 255 }), ]; +const fallbackColors = [...fallbackMockColors, ...mockColors]; + describe("ColorAllocator", () => { let allocator: ColorAllocator; @@ -50,7 +53,7 @@ describe("ColorAllocator", () => { const fallback = allocator.assignColor("4"); const fallback2 = allocator.assignColor("5"); - const match = fallbackMockColors.some((color) => color.isEqual(fallback)); + const match = fallbackColors.some((color) => color.isEqual(fallback)); expect(match).toBe(true); const match2 = fallback.isEqual(fallback2); @@ -79,3 +82,33 @@ describe("ColorAllocator", () => { expect(allocator.assignTeamColor(ColoredTeams.Bot)).toEqual(botColor); }); }); + +describe("selectDistinctColor", () => { + test("returns a distinct color when one exceeds the delta threshold", () => { + const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; // bright red + const availableColors = [ + colord({ r: 254, g: 1, b: 1 }), // too close + colord({ r: 0, g: 255, b: 0 }), // distinct green + colord({ r: 0, g: 0, b: 255 }), // distinct blue + ]; + + const result = selectDistinctColor(availableColors, assignedColors, 25); + expect(result).not.toBeNull(); + const rgb = result!.selectedColor.toRgb(); + expect([ + { r: 0, g: 255, b: 0, a: 1 }, + { r: 0, g: 0, b: 255, a: 1 }, + ]).toContainEqual(rgb); + }); + + test("returns null if all available colors are too close", () => { + const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; + const availableColors = [ + colord({ r: 250, g: 5, b: 5 }), + colord({ r: 245, g: 10, b: 10 }), + ]; + + const result = selectDistinctColor(availableColors, assignedColors, 50); + expect(result).toBeNull(); + }); +});