9.2 KiB
Implementation Plan: Scoreboard — Distinct Line Colors per Top-10 Player on Score Graph
Status
Feature NOT yet implemented. The PLAYER_COLOR_PALETTE exposes 10 distinct
hex colors and there are 10 legend entries / 10 polylines, but each colorIndex
is derived from stablePlayerColorIndex(playerId) — an FNV-1a hash modulo 10.
For many player-id sets (including the reported one) that hash produces
collisions: e.g. PlayerC and Player05 both hash to 5 → #ec4899;
Player07 and Player08 both hash to 9 → #84cc16; PlayerA and
Player10 both hash to 2 → #10b981. Verified empirically:
hash → palette index
PlayerA → 2 (#10b981) green
PlayerB → 8 (#6366f1)
PlayerC → 5 (#ec4899) pink ← collides with Player05
Player04 → 8 (#6366f1)
Player05 → 5 (#ec4899) pink ← collides with PlayerC
Player06 → 3 (#ef4444)
Player07 → 9 (#84cc16) lime ← collides with Player08
Player08 → 9 (#84cc16) lime ← collides with Player07
Player09 → 3 (#ef4444)
Player10 → 2 (#10b981) green ← collides with PlayerA
Collisions are observed across all four scoreboard tabs (Ranking swatch,
Matrix first-column swatch, Event Log has no swatch, Score Graph swatch +
polyline) because every component reads its color through the same
colorIndex → PLAYER_COLOR_PALETTE lookup.
The Job text limits the symptom to the Score Graph, but the root cause is
shared data (colorIndex), so the fix must be applied at the data layer
(scoreboard.pure.ts) and re-projected through scoreboard.store.ts so the
same top-10 player keeps the same distinct color in every tab.
1. Architectural Reconnaissance
- Framework & language: TypeScript strict mode, Angular standalone components, NestJS backend (irrelevant for this fix — the bug is purely client-side rendering).
- Code style: Standalone components with
ChangeDetectionStrategy.OnPush, signal-based store, pure-function helpers inscoreboard.pure.ts(no Angular DI), services in*.service.ts, store in*.store.ts. - Data layer: Pure-function projections over server-supplied payloads.
The four tabs are populated from
/api/v1/scoreboard/{ranking,matrix,event-log,graph}(parallel fetch inScoreboardStore.loadAll()) and incrementally patched inScoreboardStore.applySolveEvent()from the/api/v1/eventsSSEsolveframe. - Color model: A single readonly 10-entry hex array
PLAYER_COLOR_PALETTEinfrontend/src/app/features/scoreboard/scoreboard.pure.ts:73and a hashing helperstablePlayerColorIndex(playerId)(scoreboard.pure.ts:86). Every consumer (ranking, matrix, score-graph) readsPLAYER_COLOR_PALETTE[idx]directly. There is currently no collision-resolution pass. - Test framework & structure: Jest 29 with
ts-jestpreset, configured by/repo/tests/jest.config.js(two projects:backendandfrontend, the latter withjsdom). Tests live exclusively in/repo/tests/frontend/and/repo/tests/backend/(never next to source). Single command:npm testfrom the repo root. - Required tools & dependencies: None new. The fix is purely a
pure-function rewrite inside existing files plus a tiny test addition.
No
setup.shchange, no new packages.
2. Impacted Files
-
To Modify:
frontend/src/app/features/scoreboard/scoreboard.pure.ts— add a collision-free top-N palette assigner; rewire the three projection helpers (parseSolveEventIntoRanking,mutateMatrixFromSolve,applySolveToGraph) so they use it instead of barestablePlayerColorIndex.frontend/src/app/features/scoreboard/scoreboard.store.ts— apply the unique-color projection afterloadAll()populates each signal (ranking/matrix/graph) so the initial REST responses also benefit.tests/frontend/scoreboard.pure.spec.ts— add focused specs covering the new collision-free assigner (success path: distinct indices for the documented colliding ID set) and a regression spec asserting existing single-player behaviour still holds.
-
To Create: None.
3. Proposed Changes
3.1. Add a unique-color assigner in scoreboard.pure.ts
Export a new pure helper next to stablePlayerColorIndex:
export function assignUniquePlayerColors<T extends { playerId: string }>(
items: readonly T[],
getColorIndex: (t: T) => number,
capacity: number = PLAYER_COLOR_PALETTE.length,
): T[] {
const used = new Set<number>();
const out: T[] = [];
for (const item of items) {
const preferred = ((getColorIndex(item) % capacity) + capacity) % capacity;
let chosen = preferred;
if (used.has(preferred)) {
// Linear probe — the top-10 window guarantees a free slot exists.
let probe = (preferred + 1) % capacity;
while (used.has(probe) && probe !== preferred) {
probe = (probe + 1) % capacity;
}
chosen = probe;
}
used.add(chosen);
out.push({ ...item, colorIndex: chosen } as T);
}
return out;
}
Semantics:
- The first player keeps their hashed index; collisions are pushed to the
next free slot via linear probe. With
capacity = 10and at most 10 players, a free slot is always found (the input length is itself bounded to ≤ capacity by the upstreamtop-10 cutoffinapplySolveToGraph). - Order is preserved → ranking/rank-cell order is unaffected.
- The mapper is injected so the same helper works for
RankingRow,GraphSeries, and any future list type that needs acolorIndex.
3.2. Use the assigner inside the three projection helpers
In parseSolveEventIntoRanking, replace the direct assignment at the
"new player" branch (currently
colorIndex: stablePlayerColorIndex(payload.playerId)) by:
- Build a temporary list of the affected/new player with the hashed index.
- After the sort, call
assignUniquePlayerColors(sorted, (r) => r.colorIndex)and return that.
In mutateMatrixFromSolve, when a brand-new player is appended, derive
their provisional colorIndex from the hash, then post-process the
players array with assignUniquePlayerColors before returning.
In applySolveToGraph, after the new series is added and the array is
sorted + sliced to top 10, run assignUniquePlayerColors on the top-10
series so the polyline colors are guaranteed unique. New players appended
after the cutoff don't affect the top-10.
This keeps the FNV hash as the deterministic preference (so a player retains their color across all tabs and across reloads whenever possible) while guaranteeing uniqueness inside the top-10 window.
3.3. Apply the same pass after loadAll() in scoreboard.store.ts
The initial REST responses already arrive with colorIndex set by the
backend (which uses the same hashing convention). After the four
parallel fetches resolve, re-project each view through the new assigner
so the very first paint also shows distinct colors. Specifically, in
the success branch of loadAll():
this._ranking.set(assignUniquePlayerColors(ranking, (r) => r.colorIndex));
const matrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex);
this._matrix.set({ ...matrix, players: matrixPlayers });
this._graph.set({ ...graph, series: assignUniquePlayerColors(graph.series, (s) => s.colorIndex) });
(Backed by tests in §4; the projection is a no-op when there is only one player or when no collisions exist.)
3.4. Verify Ranking/Matrix tabs visually
The existing ranking.component.ts:68, matrix.component.ts:108, and
score-graph.component.ts:112 color(idx) helpers already guard
against out-of-range indices via the same modulo trick the assigner
uses, so no template changes are required — they keep working as soon as
colorIndex is unique.
4. Test Strategy
-
Target test file:
tests/frontend/scoreboard.pure.spec.ts(and one regression test forapplySolveToGraphcovering the exact Job example: ten PlayerC/Player05-style IDs). -
Mocking strategy: The helper is a pure function with no DOM, no Angular DI, and no async — it is imported directly into the existing spec file, which already mocks only
@angular/common/http(no extra setup needed). NoTestBed, no fixtures, no chart rendering. -
Spec list (minimal, focused):
assignUniquePlayerColorsreturns distinct indices for the documented colliding id set['PlayerA','PlayerB','PlayerC','Player04','Player05','Player06','Player07','Player08','Player09','Player10']and the colors they map to are all different (assert viaPLAYER_COLOR_PALETTE[idx]set size === 10).assignUniquePlayerColorsis a no-op when there are no collisions (single player, or hashed indices already unique).applySolveToGraphend-to-end: feed it an empty view + 10 SSE solves for 10 colliding players, then assert every series in the resultingview.serieshas a uniquecolorIndex(regression for the Job's DOM-inspection symptom at the polyline level).parseSolveEventIntoRankingregression: existing test still passes — proves the rewire did not change ranking sort/points logic.
-
How to run:
npm testfrom the repo root (resolves to the pre-configured Jest project; frontend project selected automatically because all impacted specs live undertests/frontend/).