From b07a59685e364aae6b2bd217abdb73343b8b593b Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 12 Jul 2025 09:30:29 -0700 Subject: [PATCH] fix color allocator not selecting distinct colors (#1404) ## Description: The color allocator only checked if DeltaE met a threshold of 25, but most colors met that threshold, so it wasn't much better than random. Now it goes down the list of assigned colors to find the most unique color to add. Also changed algorithms from deltaE76 to deltaE2000 as that seemed to produce better results. The algorithm is O(n^2) so we cap distinct check at 50 colors, after that fall back to random selection. After 50 colors our color palette is pretty much exhausted anyways. Moved ColorAllocator to its own file ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- package-lock.json | 7 ++ package.json | 1 + src/core/configuration/ColorAllocator.ts | 125 ++++++++++++++++++++++ src/core/configuration/Colors.ts | 101 +---------------- src/core/configuration/PastelTheme.ts | 9 +- src/core/configuration/PastelThemeDark.ts | 9 +- tests/Colors.test.ts | 26 ++--- 7 files changed, 144 insertions(+), 134 deletions(-) create mode 100644 src/core/configuration/ColorAllocator.ts diff --git a/package-lock.json b/package-lock.json index d053d614d..50debaf57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/msgpack5": "^3.4.6", "binary-loader": "^0.0.1", "colord": "^2.9.3", + "colorjs.io": "^0.5.2", "copy-webpack-plugin": "^13.0.0", "d3": "^7.9.0", "dompurify": "^3.1.7", @@ -11767,6 +11768,12 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "license": "MIT" + }, "node_modules/colorspace": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", diff --git a/package.json b/package.json index 2e54e7aec..49664d08a 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@types/msgpack5": "^3.4.6", "binary-loader": "^0.0.1", "colord": "^2.9.3", + "colorjs.io": "^0.5.2", "copy-webpack-plugin": "^13.0.0", "d3": "^7.9.0", "dompurify": "^3.1.7", diff --git a/src/core/configuration/ColorAllocator.ts b/src/core/configuration/ColorAllocator.ts new file mode 100644 index 000000000..0421a7daa --- /dev/null +++ b/src/core/configuration/ColorAllocator.ts @@ -0,0 +1,125 @@ +import { Colord, extend } from "colord"; +import labPlugin from "colord/plugins/lab"; +import lchPlugin from "colord/plugins/lch"; +import Color from "colorjs.io"; +import { ColoredTeams, Team } from "../game/Game"; +import { PseudoRandom } from "../PseudoRandom"; +import { simpleHash } from "../Util"; +import { + blue, + botColor, + green, + orange, + purple, + red, + teal, + yellow, +} from "./Colors"; +extend([lchPlugin]); +extend([labPlugin]); + +export class ColorAllocator { + private availableColors: Colord[]; + private fallbackColors: Colord[]; + private assigned = new Map(); + + constructor(colors: Colord[], fallback: Colord[]) { + this.availableColors = [...colors]; + 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]; + } + + let selectedIndex = 0; + + if (this.assigned.size === 0 || this.assigned.size > 50) { + // Randomly pick the first color if no colors have been assigned yet. + // + // Or if more than 50 colors assigned just pick a random one for perf reasons, + // as selecting a distinct color is O(n^2), and the color palette is mostly exhausted anyways. + const rand = new PseudoRandom(simpleHash(id)); + selectedIndex = rand.nextInt(0, this.availableColors.length); + } else { + const assignedColors = Array.from(this.assigned.values()); + selectedIndex = + selectDistinctColorIndex(this.availableColors, assignedColors) ?? 0; + } + + const color = this.availableColors.splice(selectedIndex, 1)[0]; + this.assigned.set(id, color); + return color; + } + + assignTeamColor(team: Team): Colord { + switch (team) { + case ColoredTeams.Blue: + return blue; + case ColoredTeams.Red: + return red; + case ColoredTeams.Teal: + return teal; + case ColoredTeams.Purple: + return purple; + case ColoredTeams.Yellow: + return yellow; + case ColoredTeams.Orange: + return orange; + case ColoredTeams.Green: + return green; + case ColoredTeams.Bot: + return botColor; + default: + return this.availableColors[ + simpleHash(team) % this.availableColors.length + ]; + } + } +} + +// Select a distinct color index from the available colors that +// is most different from the assigned colors +export function selectDistinctColorIndex( + availableColors: Colord[], + assignedColors: Colord[], +): number | null { + if (assignedColors.length === 0) { + throw new Error("No assigned colors"); + } + + const assignedLabColors = assignedColors.map(toColor); + + let maxDeltaE = 0; + let maxIndex = 0; + + for (let i = 0; i < availableColors.length; i++) { + const color = availableColors[i]; + const deltaE = minDeltaE(toColor(color), assignedLabColors); + if (deltaE > maxDeltaE) { + maxDeltaE = deltaE; + maxIndex = i; + } + } + return maxIndex; +} + +function minDeltaE(lab1: Color, assignedLabColors: Color[]) { + return assignedLabColors.reduce((min, assigned) => { + return Math.min(min, deltaE2000(lab1, assigned)); + }, Infinity); +} + +function deltaE2000(c1: Color, c2: Color): number { + return c1.deltaE(c2, "2000"); +} + +function toColor(colord: Colord): Color { + const lab = colord.toLab(); + return new Color("lab", [lab.l, lab.a, lab.b]); +} diff --git a/src/core/configuration/Colors.ts b/src/core/configuration/Colors.ts index e170e06cd..ec10305ab 100644 --- a/src/core/configuration/Colors.ts +++ b/src/core/configuration/Colors.ts @@ -1,9 +1,6 @@ -import { colord, Colord, extend, LabColor } from "colord"; +import { colord, Colord, extend } 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]); @@ -338,99 +335,3 @@ export const fallbackColors: Colord[] = [ colord({ r: 255, g: 240, b: 220 }), // Pastel Sand colord({ r: 255, g: 245, b: 210 }), // Soft Banana ]; - -export class ColorAllocator { - private availableColors: Colord[]; - private fallbackColors: Colord[]; - private assigned = new Map(); - - constructor(colors: Colord[], fallback: Colord[]) { - this.availableColors = [...colors]; - 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 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; - } - - assignTeamColor(team: Team): Colord { - switch (team) { - case ColoredTeams.Blue: - return blue; - case ColoredTeams.Red: - return red; - case ColoredTeams.Teal: - return teal; - case ColoredTeams.Purple: - return purple; - case ColoredTeams.Yellow: - return yellow; - case ColoredTeams.Orange: - return orange; - case ColoredTeams.Green: - return green; - case ColoredTeams.Bot: - return botColor; - default: - return this.availableColors[ - simpleHash(team) % this.availableColors.length - ]; - } - } -} - -// 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/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 11efa10bb..00ecc67db 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -3,13 +3,8 @@ import { PseudoRandom } from "../PseudoRandom"; import { PlayerType, Team, TerrainType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PlayerView } from "../game/GameView"; -import { - botColors, - ColorAllocator, - fallbackColors, - humanColors, - nationColors, -} from "./Colors"; +import { ColorAllocator } from "./ColorAllocator"; +import { botColors, fallbackColors, humanColors, nationColors } from "./Colors"; import { Theme } from "./Config"; type ColorCache = Map; diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index 33f661a46..e02a1862b 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -3,13 +3,8 @@ import { PseudoRandom } from "../PseudoRandom"; import { PlayerType, Team, TerrainType } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; import { PlayerView } from "../game/GameView"; -import { - botColors, - ColorAllocator, - fallbackColors, - humanColors, - nationColors, -} from "./Colors"; +import { ColorAllocator } from "./ColorAllocator"; +import { botColors, fallbackColors, humanColors, nationColors } from "./Colors"; import { Theme } from "./Config"; type ColorCache = Map; diff --git a/tests/Colors.test.ts b/tests/Colors.test.ts index 887b11583..a4167419a 100644 --- a/tests/Colors.test.ts +++ b/tests/Colors.test.ts @@ -1,12 +1,9 @@ import { colord, Colord } from "colord"; import { - blue, - botColor, ColorAllocator, - red, - selectDistinctColor, - teal, -} from "../src/core/configuration/Colors"; + selectDistinctColorIndex, +} from "../src/core/configuration/ColorAllocator"; +import { blue, botColor, red, teal } from "../src/core/configuration/Colors"; import { ColoredTeams } from "../src/core/game/Game"; const mockColors: Colord[] = [ @@ -84,7 +81,7 @@ describe("ColorAllocator", () => { }); describe("selectDistinctColor", () => { - test("returns a distinct color when one exceeds the delta threshold", () => { + test("returns the most distant color", () => { const assignedColors = [colord({ r: 255, g: 0, b: 0 })]; // bright red const availableColors = [ colord({ r: 254, g: 1, b: 1 }), // too close @@ -92,23 +89,12 @@ describe("selectDistinctColor", () => { colord({ r: 0, g: 0, b: 255 }), // distinct blue ]; - const result = selectDistinctColor(availableColors, assignedColors, 25); + const result = selectDistinctColorIndex(availableColors, assignedColors); expect(result).not.toBeNull(); - const rgb = result!.selectedColor.toRgb(); + const rgb = availableColors[result!].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(); - }); });