AI Implementation feature(912): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.02 (#55)

This commit was merged in pull request #55.
This commit is contained in:
2026-07-23 06:40:47 +00:00
parent a39a7daee8
commit 705530e71f
7 changed files with 429 additions and 232 deletions
@@ -1,8 +1,18 @@
import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core';
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 RenderedSeries extends GraphSeries {
pointsAttr: string;
}
@Component({
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>
</ng-container>
<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="legend">
<span
*ngFor="let s of view.series; trackBy: trackByPlayer"
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
class="legend-item"
[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>
</g>
<polyline
*ngFor="let s of view.series; trackBy: trackByPlayer"
[attr.points]="pointsAttr(s)"
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
[attr.points]="s.pointsAttr"
fill="none"
[attr.stroke]="color(s.colorIndex)"
stroke-width="2"
@@ -82,7 +92,12 @@ interface Point { x: number; y: number; }
`,
})
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 height = 320;
@@ -91,10 +106,21 @@ export class ScoreGraphComponent {
readonly padT = 16;
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(() => {
const v = this.view;
const v = this._view();
if (!v) return 0;
let max = 0;
for (const s of v.series) {
@@ -105,6 +131,16 @@ export class ScoreGraphComponent {
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 {
return this._maxValue();
}
@@ -114,24 +150,5 @@ export class ScoreGraphComponent {
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;
}
@@ -289,4 +289,65 @@ export function formatSolveDateTime(utc: string | null | undefined): string {
if (Number.isNaN(d.getTime())) return '';
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())}`;
}
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(' ');
}