feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.02
This commit is contained in:
@@ -1,202 +0,0 @@
|
|||||||
# 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 in `scoreboard.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 in
|
|
||||||
`ScoreboardStore.loadAll()`) and incrementally patched in
|
|
||||||
`ScoreboardStore.applySolveEvent()` from the `/api/v1/events` SSE
|
|
||||||
`solve` frame.
|
|
||||||
- **Color model:** A single readonly 10-entry hex array
|
|
||||||
`PLAYER_COLOR_PALETTE` in `frontend/src/app/features/scoreboard/scoreboard.pure.ts:73`
|
|
||||||
and a hashing helper `stablePlayerColorIndex(playerId)`
|
|
||||||
(`scoreboard.pure.ts:86`). Every consumer (ranking, matrix, score-graph)
|
|
||||||
reads `PLAYER_COLOR_PALETTE[idx]` directly. There is currently **no**
|
|
||||||
collision-resolution pass.
|
|
||||||
- **Test framework & structure:** Jest 29 with `ts-jest` preset, configured
|
|
||||||
by `/repo/tests/jest.config.js` (two projects: `backend` and `frontend`,
|
|
||||||
the latter with `jsdom`). Tests live exclusively in `/repo/tests/frontend/`
|
|
||||||
and `/repo/tests/backend/` (never next to source). Single command:
|
|
||||||
`npm test` from 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.sh` change, 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 bare
|
|
||||||
`stablePlayerColorIndex`.
|
|
||||||
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — apply
|
|
||||||
the unique-color projection after `loadAll()` 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`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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 = 10` and at most 10
|
|
||||||
players, a free slot is always found (the input length is itself
|
|
||||||
bounded to ≤ capacity by the upstream `top-10 cutoff` in
|
|
||||||
`applySolveToGraph`).
|
|
||||||
- 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 a `colorIndex`.
|
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||
1. Build a temporary list of the affected/new player with the hashed index.
|
|
||||||
2. 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()`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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 for `applySolveToGraph` covering 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). No `TestBed`, no fixtures, no chart rendering.
|
|
||||||
- **Spec list (minimal, focused):**
|
|
||||||
1. `assignUniquePlayerColors` returns 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 via
|
|
||||||
`PLAYER_COLOR_PALETTE[idx]` set size === 10).
|
|
||||||
2. `assignUniquePlayerColors` is a no-op when there are no collisions
|
|
||||||
(single player, or hashed indices already unique).
|
|
||||||
3. `applySolveToGraph` end-to-end: feed it an empty view + 10 SSE
|
|
||||||
solves for 10 colliding players, then assert every series in the
|
|
||||||
resulting `view.series` has a unique `colorIndex` (regression for
|
|
||||||
the Job's DOM-inspection symptom at the polyline level).
|
|
||||||
4. `parseSolveEventIntoRanking` regression: existing test still passes
|
|
||||||
— proves the rewire did not change ranking sort/points logic.
|
|
||||||
|
|
||||||
- **How to run:** `npm test` from the repo root (resolves to the
|
|
||||||
pre-configured Jest project; frontend project selected automatically
|
|
||||||
because all impacted specs live under `tests/frontend/`).
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Implementation Plan: Scoreboard Score Graph — Safe rendering under invalid/reversed event window (Job 912)
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Codebase style & conventions:**
|
||||||
|
- Backend: NestJS 10 + TypeORM, SQLite (`backend/src/...`).
|
||||||
|
- Frontend: Angular 20 standalone components with Signals, `ChangeDetectionStrategy.OnPush`, `@if`/`@for` control flow, `signal()`/`computed()` for state.
|
||||||
|
- Pure helpers live in `frontend/src/app/features/scoreboard/scoreboard.pure.ts` and are the canonical test surface; components stay presentation-only.
|
||||||
|
- Existing `ScoreGraphComponent` uses the legacy `@Input()` + `@if/*ngIf` mix; we will not refactor it (out of scope) — only fix the broken math.
|
||||||
|
- **Data Layer:** SQLite via TypeORM. The bug lives entirely in the frontend projection. No DB migration is required. (The root cause is bad admin-entered `eventStartUtc`/`eventEndUtc` values persisted in the `setting` table — we do NOT silently rewrite them; we harden the renderer.)
|
||||||
|
- **Test Framework & Structure:**
|
||||||
|
- Jest 29 with `ts-jest` and two projects in `tests/jest.config.js` (`backend` + `frontend`).
|
||||||
|
- Frontend project uses `jsdom`, `tests/frontend/jest.setup.ts`, and `transformIgnorePatterns` allow-listing `@angular`, `rxjs`, `tslib`.
|
||||||
|
- Tests live under `tests/frontend/` (already convention) — e.g. `tests/frontend/scoreboard.pure.spec.ts` exists and follows this pattern. We will add a sibling `tests/frontend/score-graph-projection.spec.ts`.
|
||||||
|
- Single test command: `npm test` (or scoped `npm run test:frontend`).
|
||||||
|
- **Required Tools & Dependencies:** None new. Existing stack (Jest 29, jsdom, `@angular/common`, `@angular/core`) is sufficient.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
|
||||||
|
### To Modify
|
||||||
|
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts`
|
||||||
|
- Add a pure helper `projectGraphPoints(view, series, dims)` that returns clamped SVG `points` strings for a single series. Encapsulates the X/Y math so the bug cannot recur in the component template. Pure, deterministic, fully unit-testable.
|
||||||
|
- Add a small helper `isValidEventWindow(startUtc, endUtc)` returning `true` only when both timestamps parse and `endMs > startMs`.
|
||||||
|
- `frontend/src/app/features/scoreboard/score-graph.component.ts`
|
||||||
|
- Replace inline `pointsAttr()` with a call to `projectGraphPoints(view, s, dims)` and a single `points` getter computed up front (via a `computed()` over `view`) so the bug fix is a single source of truth.
|
||||||
|
- Switch the per-series `<polyline>` binding from a method call to a precomputed `string` per series (Angular calls methods on every change-detection cycle, so hoisting into `computed()` also helps OnPush perf).
|
||||||
|
- No template/TDD-required structural changes beyond `[attr.points]="linePoints(s)"` consuming the hoisted value.
|
||||||
|
|
||||||
|
### To Create
|
||||||
|
- `tests/frontend/score-graph-projection.spec.ts`
|
||||||
|
- Unit tests for the new pure helper covering:
|
||||||
|
1. Happy path (start < end, normal window) — unchanged coordinates.
|
||||||
|
2. Reversed window (`endUtc < startUtc`) — every projected X coordinate falls inside `[padL, padL + innerW]` and no coordinate exceeds plausible SVG bounds.
|
||||||
|
3. Equal start/end (zero span) — handled gracefully (no NaN/Infinity, all points clamp to the right edge).
|
||||||
|
4. Invalid `Date` strings — points string is empty / coordinates stay finite.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
|
||||||
|
### Step 1 — Pure helper in `scoreboard.pure.ts`
|
||||||
|
|
||||||
|
Add two exports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface GraphProjectionDims {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
padL: number;
|
||||||
|
padR: number;
|
||||||
|
padT: number;
|
||||||
|
padB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEventWindow(
|
||||||
|
startUtc: string | null,
|
||||||
|
endUtc: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (!startUtc || !endUtc) return false;
|
||||||
|
const s = new Date(startUtc).getTime();
|
||||||
|
const e = new Date(endUtc).getTime();
|
||||||
|
if (!Number.isFinite(s) || !Number.isFinite(e)) return false;
|
||||||
|
return e > s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectGraphPoints(
|
||||||
|
view: Pick<GraphView, 'startUtc' | 'endUtc'>,
|
||||||
|
series: GraphSeries,
|
||||||
|
dims: GraphProjectionDims,
|
||||||
|
): string {
|
||||||
|
if (!isValidEventWindow(view.startUtc, view.endUtc)) return '';
|
||||||
|
const startMs = new Date(view.startUtc!).getTime();
|
||||||
|
const endMs = new Date(view.endUtc!).getTime();
|
||||||
|
const span = endMs - startMs;
|
||||||
|
const innerW = dims.width - dims.padL - dims.padR;
|
||||||
|
const innerH = dims.height - dims.padT - dims.padB;
|
||||||
|
|
||||||
|
let maxVal = 0;
|
||||||
|
for (const s of series.points) if (s.value > maxVal) maxVal = s.value;
|
||||||
|
if (maxVal <= 0) maxVal = 1;
|
||||||
|
|
||||||
|
const left = dims.padL;
|
||||||
|
const right = dims.padL + innerW;
|
||||||
|
const top = dims.padT;
|
||||||
|
const bottom = dims.padT + innerH;
|
||||||
|
|
||||||
|
return series.points
|
||||||
|
.map((p) => {
|
||||||
|
const ms = new Date(p.tUtc).getTime();
|
||||||
|
if (!Number.isFinite(ms)) return `${left.toFixed(2)},${bottom.toFixed(2)}`;
|
||||||
|
const rawX = ((ms - startMs) / span) * innerW;
|
||||||
|
const x = Math.min(right, Math.max(left, rawX));
|
||||||
|
const rawY = (p.value / maxVal) * innerH;
|
||||||
|
const y = top + Math.max(0, Math.min(innerH, innerH - rawY));
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this fixes the bug:
|
||||||
|
- `isValidEventWindow` short-circuits the case `endUtc < startUtc` (the persisted repro). The component then renders the existing `Not started`/`No solves yet` empty branch instead of emitting absurd polylines. We do not silently mutate the persisted settings — we just refuse to render an off-canvas chart for an invalid window.
|
||||||
|
- `Math.min/Math.max` clamp surviving X coordinates into `[padL, width - padR]` so future regressions that still produce a malformed span (zero, NaN) cannot escape the SVG viewport.
|
||||||
|
- NaN guards prevent the `toFixed(2)`-on-`NaN` failure mode that previously produced strings like `40.00,292.00`.
|
||||||
|
|
||||||
|
### Step 2 — Component refactor in `score-graph.component.ts`
|
||||||
|
|
||||||
|
- Replace the `@Input() view` with a `signal`-style getter via `ngOnChanges`/`input()` semantics: keep `@Input() view: GraphView | null = null;` (existing pattern) but add a `computed` `seriesWithPoints` that maps each series to `{ ...s, pointsAttr: projectGraphPoints(view, s, dims) }`. Use an explicit `dims: GraphProjectionDims` constant built from the existing `width/height/padL/padR/padT/padB` so the magic numbers are declared once.
|
||||||
|
- Replace the `[attr.points]="pointsAttr(s)"` binding with `[attr.points]="line.pointsAttr"` and iterate over `seriesWithPoints()` instead of `view.series`. (Template keeps `*ngFor` — we are not upgrading to `@for` per job scope.)
|
||||||
|
- Keep all existing `data-testid` markers and the three empty states (`unconfigured` / `countdown` / running-with-empty-series) — the fix is a no-op for those branches.
|
||||||
|
|
||||||
|
### Step 3 — No backend / no DB changes
|
||||||
|
|
||||||
|
The persisted bad data (start > end) stays as-is. The renderer now refuses to project points outside the SVG and gracefully returns empty for the invalid window. We will NOT auto-swap start/end at the backend because that would silently rewrite admin input.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
- **Target Unit Test File:** `tests/frontend/score-graph-projection.spec.ts` (new). It mirrors the existing `tests/frontend/scoreboard.pure.spec.ts` style: plain Jest, no TestBed needed (pure helper), no Angular bootstrap, no DOM rendering.
|
||||||
|
- **Mocking Strategy:**
|
||||||
|
- No mocks required — the helper is a pure function over primitives and date strings.
|
||||||
|
- No HTTP, no time mocking (the helper accepts timestamps as arguments; we pin them in fixtures).
|
||||||
|
- **Tests to include (minimal, focused):**
|
||||||
|
1. `isValidEventWindow` returns `false` when `endUtc < startUtc`, `false` when equal, `false` for non-finite Date strings, `true` only when `end > start`.
|
||||||
|
2. `projectGraphPoints` with the documented repro (`startUtc=2026-07-23T00:00:00.000Z`, `endUtc=2026-07-22T00:00:00.000Z`) returns the empty string — explicitly asserting the bug-class fix.
|
||||||
|
3. `projectGraphPoints` with a normal window returns coordinates where the first point's X is `padL` and the last X is at or below `padL + innerW` (regression guard).
|
||||||
|
4. `projectGraphPoints` with `startUtc === endUtc` (zero-span edge) returns only finite, in-bounds X coordinates (no `Infinity`, no negative).
|
||||||
|
- **Negative coverage deliberately omitted:** we do not mount the Angular component (no TestBed, no fixture) — that keeps the suite fast and matches the rule "Frontend tests focus on logic, not visuals."
|
||||||
|
- **Run command:** `npm test` (executes all projects) or `npm run test:frontend` for the frontend project only. Both commands already work in this repo.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Not yet implemented. Pure helper `projectGraphPoints` / `isValidEventWindow` do not exist in `scoreboard.pure.ts`; the bug is reproducible from the current `pointsAttr()` method on line 117 of `score-graph.component.ts`.
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { GraphView, PLAYER_COLOR_PALETTE } from './scoreboard.pure';
|
import {
|
||||||
|
GraphProjectionDims,
|
||||||
|
GraphView,
|
||||||
|
GraphSeries,
|
||||||
|
PLAYER_COLOR_PALETTE,
|
||||||
|
isValidEventWindow,
|
||||||
|
projectGraphPoints,
|
||||||
|
} from './scoreboard.pure';
|
||||||
|
|
||||||
interface Point { x: number; y: number; }
|
interface Point { x: number; y: number; }
|
||||||
|
interface RenderedSeries extends GraphSeries {
|
||||||
|
pointsAttr: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scoreboard-graph',
|
selector: 'app-scoreboard-graph',
|
||||||
@@ -38,11 +48,11 @@ interface Point { x: number; y: number; }
|
|||||||
<p class="not-started" data-testid="score-graph-not-started">Not started</p>
|
<p class="not-started" data-testid="score-graph-not-started">Not started</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngSwitchDefault>
|
<ng-container *ngSwitchDefault>
|
||||||
<ng-container *ngIf="view && view.series.length; else emptySeries">
|
<ng-container *ngIf="renderedSeries().length; else emptySeries">
|
||||||
<div class="frame" data-testid="score-graph">
|
<div class="frame" data-testid="score-graph">
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span
|
<span
|
||||||
*ngFor="let s of view.series; trackBy: trackByPlayer"
|
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
|
||||||
class="legend-item"
|
class="legend-item"
|
||||||
[attr.data-testid]="'score-graph-legend-' + s.playerId"
|
[attr.data-testid]="'score-graph-legend-' + s.playerId"
|
||||||
>
|
>
|
||||||
@@ -64,8 +74,8 @@ interface Point { x: number; y: number; }
|
|||||||
<text [attr.x]="padL - 4" [attr.y]="padT + 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">{{ maxValue() }}</text>
|
<text [attr.x]="padL - 4" [attr.y]="padT + 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">{{ maxValue() }}</text>
|
||||||
</g>
|
</g>
|
||||||
<polyline
|
<polyline
|
||||||
*ngFor="let s of view.series; trackBy: trackByPlayer"
|
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
|
||||||
[attr.points]="pointsAttr(s)"
|
[attr.points]="s.pointsAttr"
|
||||||
fill="none"
|
fill="none"
|
||||||
[attr.stroke]="color(s.colorIndex)"
|
[attr.stroke]="color(s.colorIndex)"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
@@ -82,7 +92,12 @@ interface Point { x: number; y: number; }
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ScoreGraphComponent {
|
export class ScoreGraphComponent {
|
||||||
@Input() view: GraphView | null = null;
|
@Input() set view(v: GraphView | null) {
|
||||||
|
this._view.set(v);
|
||||||
|
}
|
||||||
|
get view(): GraphView | null {
|
||||||
|
return this._view();
|
||||||
|
}
|
||||||
|
|
||||||
readonly width = 800;
|
readonly width = 800;
|
||||||
readonly height = 320;
|
readonly height = 320;
|
||||||
@@ -91,10 +106,21 @@ export class ScoreGraphComponent {
|
|||||||
readonly padT = 16;
|
readonly padT = 16;
|
||||||
readonly padB = 28;
|
readonly padB = 28;
|
||||||
|
|
||||||
readonly state = computed(() => this.view?.state ?? 'unconfigured');
|
private readonly _view = signal<GraphView | null>(null);
|
||||||
|
|
||||||
|
private readonly dims: GraphProjectionDims = {
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
padL: this.padL,
|
||||||
|
padR: this.padR,
|
||||||
|
padT: this.padT,
|
||||||
|
padB: this.padB,
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly state = computed(() => this._view()?.state ?? 'unconfigured');
|
||||||
|
|
||||||
private readonly _maxValue = computed(() => {
|
private readonly _maxValue = computed(() => {
|
||||||
const v = this.view;
|
const v = this._view();
|
||||||
if (!v) return 0;
|
if (!v) return 0;
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const s of v.series) {
|
for (const s of v.series) {
|
||||||
@@ -105,6 +131,16 @@ export class ScoreGraphComponent {
|
|||||||
return max;
|
return max;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly renderedSeries = computed<RenderedSeries[]>(() => {
|
||||||
|
const v = this._view();
|
||||||
|
if (!v) return [];
|
||||||
|
if (!isValidEventWindow(v.startUtc, v.endUtc)) return [];
|
||||||
|
return v.series.map((s) => ({
|
||||||
|
...s,
|
||||||
|
pointsAttr: projectGraphPoints(v, s, this.dims),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
maxValue(): number {
|
maxValue(): number {
|
||||||
return this._maxValue();
|
return this._maxValue();
|
||||||
}
|
}
|
||||||
@@ -114,24 +150,5 @@ export class ScoreGraphComponent {
|
|||||||
return PLAYER_COLOR_PALETTE[safe];
|
return PLAYER_COLOR_PALETTE[safe];
|
||||||
}
|
}
|
||||||
|
|
||||||
pointsAttr(s: { points: { tUtc: string; value: number }[] }): string {
|
|
||||||
const v = this.view;
|
|
||||||
if (!v || !v.startUtc || !v.endUtc) return '';
|
|
||||||
const startMs = new Date(v.startUtc).getTime();
|
|
||||||
const endMs = new Date(v.endUtc).getTime();
|
|
||||||
const span = Math.max(1, endMs - startMs);
|
|
||||||
const maxVal = Math.max(1, this._maxValue());
|
|
||||||
const innerW = this.width - this.padL - this.padR;
|
|
||||||
const innerH = this.height - this.padT - this.padB;
|
|
||||||
return s.points
|
|
||||||
.map((p) => {
|
|
||||||
const ms = new Date(p.tUtc).getTime();
|
|
||||||
const x = this.padL + ((ms - startMs) / span) * innerW;
|
|
||||||
const y = this.padT + innerH - (p.value / maxVal) * innerH;
|
|
||||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
trackByPlayer = (_: number, s: { playerId: string }) => s.playerId;
|
trackByPlayer = (_: number, s: { playerId: string }) => s.playerId;
|
||||||
}
|
}
|
||||||
@@ -289,4 +289,65 @@ export function formatSolveDateTime(utc: string | null | undefined): string {
|
|||||||
if (Number.isNaN(d.getTime())) return '';
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphProjectionDims {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
padL: number;
|
||||||
|
padR: number;
|
||||||
|
padT: number;
|
||||||
|
padB: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEventWindow(
|
||||||
|
startUtc: string | null | undefined,
|
||||||
|
endUtc: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!startUtc || !endUtc) return false;
|
||||||
|
const s = new Date(startUtc).getTime();
|
||||||
|
const e = new Date(endUtc).getTime();
|
||||||
|
if (!Number.isFinite(s) || !Number.isFinite(e)) return false;
|
||||||
|
return e > s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectGraphPoints(
|
||||||
|
view: Pick<GraphView, 'startUtc' | 'endUtc'>,
|
||||||
|
series: GraphSeries,
|
||||||
|
dims: GraphProjectionDims,
|
||||||
|
): string {
|
||||||
|
if (!isValidEventWindow(view.startUtc, view.endUtc)) return '';
|
||||||
|
const startMs = new Date(view.startUtc as string).getTime();
|
||||||
|
const endMs = new Date(view.endUtc as string).getTime();
|
||||||
|
const span = endMs - startMs;
|
||||||
|
const innerW = dims.width - dims.padL - dims.padR;
|
||||||
|
const innerH = dims.height - dims.padT - dims.padB;
|
||||||
|
|
||||||
|
let maxVal = 0;
|
||||||
|
for (const p of series.points) {
|
||||||
|
if (p.value > maxVal) maxVal = p.value;
|
||||||
|
}
|
||||||
|
if (maxVal <= 0) maxVal = 1;
|
||||||
|
|
||||||
|
const left = dims.padL;
|
||||||
|
const right = dims.padL + innerW;
|
||||||
|
const top = dims.padT;
|
||||||
|
const bottom = dims.padT + innerH;
|
||||||
|
|
||||||
|
return series.points
|
||||||
|
.map((p) => {
|
||||||
|
const ms = new Date(p.tUtc).getTime();
|
||||||
|
let x: number;
|
||||||
|
if (!Number.isFinite(ms)) {
|
||||||
|
x = left;
|
||||||
|
} else {
|
||||||
|
const rawX = ((ms - startMs) / span) * innerW;
|
||||||
|
x = Math.min(right, Math.max(left, rawX));
|
||||||
|
}
|
||||||
|
const rawY = (p.value / maxVal) * innerH;
|
||||||
|
const yClamped = Math.max(0, Math.min(innerH, innerH - rawY));
|
||||||
|
const y = top + yClamped;
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
jest.mock('@angular/common/http', () => ({
|
||||||
|
HttpClient: class {},
|
||||||
|
HttpErrorResponse: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
GraphProjectionDims,
|
||||||
|
GraphSeries,
|
||||||
|
isValidEventWindow,
|
||||||
|
projectGraphPoints,
|
||||||
|
} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
|
||||||
|
|
||||||
|
const DIMS: GraphProjectionDims = {
|
||||||
|
width: 800,
|
||||||
|
height: 320,
|
||||||
|
padL: 40,
|
||||||
|
padR: 16,
|
||||||
|
padT: 16,
|
||||||
|
padB: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerW = DIMS.width - DIMS.padL - DIMS.padR;
|
||||||
|
const innerH = DIMS.height - DIMS.padT - DIMS.padB;
|
||||||
|
const xMin = DIMS.padL;
|
||||||
|
const xMax = DIMS.padL + innerW;
|
||||||
|
const yMin = DIMS.padT;
|
||||||
|
const yMax = DIMS.padT + innerH;
|
||||||
|
|
||||||
|
function parsePoints(str: string): Array<{ x: number; y: number }> {
|
||||||
|
if (!str) return [];
|
||||||
|
return str.split(' ').map((pair) => {
|
||||||
|
const [xs, ys] = pair.split(',');
|
||||||
|
return { x: Number(xs), y: Number(ys) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isValidEventWindow', () => {
|
||||||
|
it('returns false when end is before start', () => {
|
||||||
|
expect(
|
||||||
|
isValidEventWindow('2026-07-23T00:00:00.000Z', '2026-07-22T00:00:00.000Z'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when start equals end', () => {
|
||||||
|
expect(
|
||||||
|
isValidEventWindow('2026-07-23T00:00:00.000Z', '2026-07-23T00:00:00.000Z'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-finite Date strings', () => {
|
||||||
|
expect(isValidEventWindow('not a date', '2026-07-22T00:00:00.000Z')).toBe(false);
|
||||||
|
expect(isValidEventWindow('2026-07-23T00:00:00.000Z', 'garbage')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for null/undefined', () => {
|
||||||
|
expect(isValidEventWindow(null, '2026-07-22T00:00:00.000Z')).toBe(false);
|
||||||
|
expect(isValidEventWindow('2026-07-23T00:00:00.000Z', undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true only when end is strictly after start', () => {
|
||||||
|
expect(
|
||||||
|
isValidEventWindow('2026-07-22T00:00:00.000Z', '2026-07-23T00:00:00.000Z'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectGraphPoints — reversed/invalid window (Job 912 repro)', () => {
|
||||||
|
const view = {
|
||||||
|
startUtc: '2026-07-23T00:00:00.000Z',
|
||||||
|
endUtc: '2026-07-22T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const series: GraphSeries = {
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'p1',
|
||||||
|
colorIndex: 0,
|
||||||
|
points: [
|
||||||
|
{ tUtc: '2026-07-22T12:00:00.000Z', value: 0 },
|
||||||
|
{ tUtc: '2026-07-22T18:00:00.000Z', value: 100 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns empty string when endUtc is before startUtc (no off-canvas polyline)', () => {
|
||||||
|
expect(projectGraphPoints(view, series, DIMS)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders zero series for the component when the event window is reversed', () => {
|
||||||
|
const out = projectGraphPoints(view, series, DIMS);
|
||||||
|
expect(parsePoints(out)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectGraphPoints — happy path', () => {
|
||||||
|
const view = {
|
||||||
|
startUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
endUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const series: GraphSeries = {
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'p1',
|
||||||
|
colorIndex: 0,
|
||||||
|
points: [
|
||||||
|
{ tUtc: '2025-01-01T00:00:00.000Z', value: 0 },
|
||||||
|
{ tUtc: '2025-01-01T12:00:00.000Z', value: 50 },
|
||||||
|
{ tUtc: '2025-01-02T00:00:00.000Z', value: 100 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('anchors the first point at padL and keeps every X inside the plot area', () => {
|
||||||
|
const out = projectGraphPoints(view, series, DIMS);
|
||||||
|
const pts = parsePoints(out);
|
||||||
|
expect(pts.length).toBe(3);
|
||||||
|
pts.forEach((p) => {
|
||||||
|
expect(Number.isFinite(p.x)).toBe(true);
|
||||||
|
expect(p.x).toBeGreaterThanOrEqual(xMin);
|
||||||
|
expect(p.x).toBeLessThanOrEqual(xMax);
|
||||||
|
expect(p.y).toBeGreaterThanOrEqual(yMin);
|
||||||
|
expect(p.y).toBeLessThanOrEqual(yMax);
|
||||||
|
});
|
||||||
|
expect(pts[0].x).toBeCloseTo(xMin, 2);
|
||||||
|
expect(pts[pts.length - 1].x).toBeLessThanOrEqual(xMax + 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not regress to the previous off-canvas X values', () => {
|
||||||
|
const out = projectGraphPoints(view, series, DIMS);
|
||||||
|
expect(out).not.toMatch(/16965896296/);
|
||||||
|
expect(out).not.toMatch(/-64281599960/);
|
||||||
|
expect(out).not.toMatch(/NaN/);
|
||||||
|
expect(out).not.toMatch(/Infinity/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectGraphPoints — zero-span and invalid timestamps', () => {
|
||||||
|
it('returns empty string when startUtc equals endUtc', () => {
|
||||||
|
const view = {
|
||||||
|
startUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
endUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const series: GraphSeries = {
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'p1',
|
||||||
|
colorIndex: 0,
|
||||||
|
points: [{ tUtc: '2025-01-01T00:00:00.000Z', value: 10 }],
|
||||||
|
};
|
||||||
|
expect(projectGraphPoints(view, series, DIMS)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces only finite, in-bounds coordinates when a point timestamp is invalid', () => {
|
||||||
|
const view = {
|
||||||
|
startUtc: '2025-01-01T00:00:00.000Z',
|
||||||
|
endUtc: '2025-01-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const series: GraphSeries = {
|
||||||
|
playerId: 'p1',
|
||||||
|
playerName: 'p1',
|
||||||
|
colorIndex: 0,
|
||||||
|
points: [
|
||||||
|
{ tUtc: 'not-a-date', value: 50 },
|
||||||
|
{ tUtc: '2025-01-01T12:00:00.000Z', value: 100 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = projectGraphPoints(view, series, DIMS);
|
||||||
|
const pts = parsePoints(out);
|
||||||
|
expect(pts.length).toBe(2);
|
||||||
|
pts.forEach((p) => {
|
||||||
|
expect(Number.isFinite(p.x)).toBe(true);
|
||||||
|
expect(Number.isFinite(p.y)).toBe(true);
|
||||||
|
expect(p.x).toBeGreaterThanOrEqual(xMin);
|
||||||
|
expect(p.x).toBeLessThanOrEqual(xMax);
|
||||||
|
expect(p.y).toBeGreaterThanOrEqual(yMin);
|
||||||
|
expect(p.y).toBeLessThanOrEqual(yMax);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user