From d6fa339755ce4e92f1fcc4baa1b5271326ed3be6 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 19 Jan 2026 15:33:42 -0700 Subject: [PATCH] Add more color variations --- constants.ts | 24 +++---- tests/constants.test.ts | 142 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/constants.ts b/constants.ts index 14546f1..f990e7b 100644 --- a/constants.ts +++ b/constants.ts @@ -101,17 +101,13 @@ export const getPlayerRank = (playerId: string, players: Player[]): number => { return sorted.findIndex(p => p.id === playerId) + 1; }; -export const PLAYER_COLORS = [ - '#2563eb', - '#e21b3c', - '#26890c', - '#ffa602', - '#d89e00', - '#0aa3a3', - '#b8008a', - '#6a4c93', - '#ff6b6b', - '#4ecdc4', - '#45b7d1', - '#8b5cf6', -]; +const GOLDEN_RATIO = 0.618033988749895; + +export const generatePlayerColor = (index: number): string => { + const hue = ((index * GOLDEN_RATIO) % 1) * 360; + const saturation = 70 + (index % 3) * 10; + const lightness = 45 + (index % 2) * 10; + return `hsl(${Math.round(hue)}, ${saturation}%, ${lightness}%)`; +}; + +export const PLAYER_COLORS = Array.from({ length: 50 }, (_, i) => generatePlayerColor(i)); diff --git a/tests/constants.test.ts b/tests/constants.test.ts index 66331e7..b4898a6 100644 --- a/tests/constants.test.ts +++ b/tests/constants.test.ts @@ -3,6 +3,8 @@ import { calculateBasePoints, calculatePointsWithBreakdown, getPlayerRank, + generatePlayerColor, + PLAYER_COLORS, POINTS_PER_QUESTION, QUESTION_TIME_MS, } from '../constants'; @@ -563,3 +565,143 @@ describe('getPlayerRank', () => { }); }); }); + +describe('generatePlayerColor', () => { + describe('output format', () => { + it('returns valid HSL format', () => { + const color = generatePlayerColor(0); + expect(color).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + }); + + it('returns valid HSL values within correct ranges', () => { + for (let i = 0; i < 20; i++) { + const color = generatePlayerColor(i); + const match = color.match(/^hsl\((\d+), (\d+)%, (\d+)%\)$/); + + expect(match).not.toBeNull(); + const [, hue, saturation, lightness] = match!.map(Number); + + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + expect(saturation).toBeGreaterThanOrEqual(70); + expect(saturation).toBeLessThanOrEqual(90); + expect(lightness).toBeGreaterThanOrEqual(45); + expect(lightness).toBeLessThanOrEqual(55); + } + }); + }); + + describe('determinism', () => { + it('returns same color for same index', () => { + const color1 = generatePlayerColor(5); + const color2 = generatePlayerColor(5); + expect(color1).toBe(color2); + }); + + it('returns consistent colors across multiple calls', () => { + const colors = Array.from({ length: 10 }, (_, i) => generatePlayerColor(i)); + const colorsAgain = Array.from({ length: 10 }, (_, i) => generatePlayerColor(i)); + expect(colors).toEqual(colorsAgain); + }); + }); + + describe('color distribution (golden ratio)', () => { + it('generates distinct hues for consecutive indices (golden ratio creates ~137° separation)', () => { + const hues: number[] = []; + for (let i = 0; i < 10; i++) { + const color = generatePlayerColor(i); + const match = color.match(/^hsl\((\d+),/); + hues.push(Number(match![1])); + } + + for (let i = 1; i < hues.length; i++) { + const diff = Math.abs(hues[i] - hues[i - 1]); + const wrappedDiff = Math.min(diff, 360 - diff); + expect(wrappedDiff).toBeGreaterThan(30); + } + }); + + it('generates well-distributed colors for large player counts', () => { + const hues = new Set(); + for (let i = 0; i < 50; i++) { + const color = generatePlayerColor(i); + const match = color.match(/^hsl\((\d+),/); + hues.add(Number(match![1])); + } + + expect(hues.size).toBeGreaterThan(30); + }); + + it('produces 3 saturation levels (70, 80, 90)', () => { + const saturations = new Set(); + for (let i = 0; i < 10; i++) { + const color = generatePlayerColor(i); + const match = color.match(/, (\d+)%,/); + saturations.add(Number(match![1])); + } + + expect(saturations.size).toBe(3); + }); + + it('produces 2 lightness levels (45, 55)', () => { + const lightnesses = new Set(); + for (let i = 0; i < 10; i++) { + const color = generatePlayerColor(i); + const match = color.match(/, (\d+)%\)$/); + lightnesses.add(Number(match![1])); + } + + expect(lightnesses.size).toBe(2); + }); + }); + + describe('edge cases', () => { + it('handles index 0', () => { + const color = generatePlayerColor(0); + expect(color).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + }); + + it('handles large indices', () => { + const color = generatePlayerColor(1000); + expect(color).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + + const match = color.match(/^hsl\((\d+), (\d+)%, (\d+)%\)$/); + const [, hue, sat, light] = match!.map(Number); + expect(hue).toBeLessThan(360); + expect(sat).toBeLessThanOrEqual(90); + expect(light).toBeLessThanOrEqual(55); + }); + + it('handles negative indices gracefully', () => { + const color = generatePlayerColor(-1); + expect(color).toMatch(/^hsl\(-?\d+, \d+%, \d+%\)$/); + }); + }); +}); + +describe('PLAYER_COLORS', () => { + it('contains exactly 50 colors', () => { + expect(PLAYER_COLORS).toHaveLength(50); + }); + + it('all colors are valid HSL strings', () => { + for (const color of PLAYER_COLORS) { + expect(color).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/); + } + }); + + it('first color matches generatePlayerColor(0)', () => { + expect(PLAYER_COLORS[0]).toBe(generatePlayerColor(0)); + }); + + it('colors are generated in order', () => { + for (let i = 0; i < PLAYER_COLORS.length; i++) { + expect(PLAYER_COLORS[i]).toBe(generatePlayerColor(i)); + } + }); + + it('has 20 unique colors for first 20 indices (typical game size)', () => { + const uniqueColors = new Set(PLAYER_COLORS.slice(0, 20)); + expect(uniqueColors.size).toBe(20); + }); +});