mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
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:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user