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:
@@ -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(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user