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(); - }); });