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
This commit is contained in:
evanpelle
2025-07-12 09:30:29 -07:00
committed by GitHub
parent 0d7c58d5b8
commit b07a59685e
7 changed files with 144 additions and 134 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+125
View File
@@ -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<string, Colord>();
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]);
}
+1 -100
View File
@@ -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<string, Colord>();
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),
);
}
+2 -7
View File
@@ -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<string, Colord>;
+2 -7
View File
@@ -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<string, Colord>;
+6 -20
View File
@@ -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();
});
});