diff --git a/proxy.conf.json b/proxy.conf.json
index f243619..e65b255 100644
--- a/proxy.conf.json
+++ b/proxy.conf.json
@@ -1,4 +1,9 @@
{
+ "/api/analysis": {
+ "target": "http://localhost:8087",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api/tournament": {
"target": "http://localhost:8089",
"secure": false,
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 54f7663..63b01eb 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component';
+import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
@@ -14,6 +15,7 @@ export const routes: Routes = [
{ path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent },
+ { path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent },
- { path: '**', redirectTo: '' }
+ { path: '**', redirectTo: '' },
];
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 2f546f6..91e7669 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { provideHttpClient } from '@angular/common/http';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
- providers: [provideRouter([])]
+ providers: [provideRouter([]), provideHttpClient()],
}).compileComponents();
});
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.css b/src/app/components/annotated-move-list/annotated-move-list.component.css
new file mode 100644
index 0000000..6062a45
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.css
@@ -0,0 +1,108 @@
+:host {
+ display: block;
+}
+
+.empty {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+ padding: 12px 16px;
+ font-family: var(--nc-mono, monospace);
+ letter-spacing: 0.06em;
+}
+
+.move-grid {
+ display: grid;
+ grid-template-columns: 28px 1fr 1fr;
+ gap: 2px 4px;
+ padding: 8px 12px;
+ font-family: var(--nc-mono, monospace);
+ font-size: 12px;
+}
+
+.mv-num {
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 10px;
+ display: flex;
+ align-items: center;
+ letter-spacing: 0.04em;
+}
+
+.mv {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 2px;
+ color: rgba(255, 255, 255, 0.8);
+ transition:
+ background 0.12s,
+ color 0.12s;
+}
+
+.mv:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: #fff;
+}
+
+.mv.active {
+ background: rgba(255, 69, 200, 0.18);
+ color: #fff;
+}
+
+.mv-empty {
+ cursor: default;
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.mv-empty:hover {
+ background: transparent;
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.mv-san {
+ flex: 1;
+}
+
+.mv-placeholder {
+ opacity: 0.4;
+}
+
+/* Quality badges */
+.mv-badge {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 4px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.q-brilliant .mv-badge,
+.mv-badge.q-brilliant {
+ color: #5ee5a1;
+ background: rgba(94, 229, 161, 0.15);
+}
+
+.q-best .mv-badge,
+.mv-badge.q-best {
+ color: #5ee5a1;
+ background: rgba(94, 229, 161, 0.1);
+}
+
+.q-inaccuracy .mv-badge,
+.mv-badge.q-inaccuracy {
+ color: #ffb13a;
+ background: rgba(255, 177, 58, 0.15);
+}
+
+.q-mistake .mv-badge,
+.mv-badge.q-mistake {
+ color: #ff7a7a;
+ background: rgba(255, 122, 122, 0.15);
+}
+
+.q-blunder .mv-badge,
+.mv-badge.q-blunder {
+ color: #ff4444;
+ background: rgba(255, 68, 68, 0.18);
+}
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.html b/src/app/components/annotated-move-list/annotated-move-list.component.html
new file mode 100644
index 0000000..111aa14
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.html
@@ -0,0 +1,48 @@
+@if (moves.length === 0) {
+
No annotated moves yet.
+} @else {
+
+ @for (pair of pairs; track $index) {
+
{{ $index + 1 }}
+
+
+ {{ pair.white?.san ?? '' }}
+ @if (qualityLabel(pair.white)) {
+ {{
+ qualityLabel(pair.white)
+ }}
+ }
+
+
+
+ @if (pair.black) {
+ {{ pair.black.san }}
+ @if (qualityLabel(pair.black)) {
+ {{
+ qualityLabel(pair.black)
+ }}
+ }
+ } @else {
+ …
+ }
+
+ }
+
+}
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.ts b/src/app/components/annotated-move-list/annotated-move-list.component.ts
new file mode 100644
index 0000000..b7d7a66
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.ts
@@ -0,0 +1,83 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AnnotatedMove, MoveQuality } from '../../models/analysis.models';
+
+interface AnnotatedPair {
+ white: AnnotatedMove | null;
+ black: AnnotatedMove | null;
+}
+
+const QUALITY_LABELS: Record = {
+ brilliant: '!!',
+ best: '!',
+ good: '',
+ inaccuracy: '?!',
+ mistake: '?',
+ blunder: '??',
+};
+
+const QUALITY_CLASSES: Record = {
+ brilliant: 'q-brilliant',
+ best: 'q-best',
+ good: 'q-good',
+ inaccuracy: 'q-inaccuracy',
+ mistake: 'q-mistake',
+ blunder: 'q-blunder',
+};
+
+@Component({
+ selector: 'app-annotated-move-list',
+ standalone: true,
+ imports: [],
+ templateUrl: './annotated-move-list.component.html',
+ styleUrl: './annotated-move-list.component.css',
+})
+export class AnnotatedMoveListComponent {
+ @Input({ required: true }) moves: AnnotatedMove[] = [];
+ @Input() activePly: number | null = null;
+ @Output() plySelected = new EventEmitter();
+
+ get pairs(): AnnotatedPair[] {
+ const result: AnnotatedPair[] = [];
+ for (let i = 0; i < this.moves.length; i += 2) {
+ result.push({
+ white: this.moves[i] ?? null,
+ black: this.moves[i + 1] ?? null,
+ });
+ }
+ return result;
+ }
+
+ qualityLabel(move: AnnotatedMove | null): string {
+ if (!move?.quality) return '';
+ return QUALITY_LABELS[move.quality] ?? '';
+ }
+
+ qualityClass(move: AnnotatedMove | null): string {
+ if (!move?.quality) return '';
+ return QUALITY_CLASSES[move.quality] ?? '';
+ }
+
+ isWhiteActive(pairIndex: number): boolean {
+ return this.activePly === pairIndex * 2;
+ }
+
+ isBlackActive(pairIndex: number): boolean {
+ return this.activePly === pairIndex * 2 + 1;
+ }
+
+ selectWhite(pairIndex: number): void {
+ this.plySelected.emit(pairIndex * 2);
+ }
+
+ selectBlack(pairIndex: number, black: AnnotatedMove | null): void {
+ if (!black) return;
+ this.plySelected.emit(pairIndex * 2 + 1);
+ }
+
+ formatEval(move: AnnotatedMove | null): string {
+ if (!move || move.evalAfter === null) return '';
+ const v = move.evalAfter;
+ const sign = v > 0 ? '+' : '';
+ return `${sign}${v.toFixed(2)}`;
+ }
+}
diff --git a/src/app/components/eval-timeline/eval-timeline.component.css b/src/app/components/eval-timeline/eval-timeline.component.css
new file mode 100644
index 0000000..fc29110
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.css
@@ -0,0 +1,53 @@
+:host {
+ display: block;
+ width: 100%;
+}
+
+.timeline-wrap {
+ width: 100%;
+ overflow: hidden;
+}
+
+.timeline-svg {
+ display: block;
+ width: 100%;
+ height: 80px;
+}
+
+.midline {
+ stroke: rgba(255, 255, 255, 0.12);
+ stroke-width: 1;
+ stroke-dasharray: 4 4;
+}
+
+.area-white {
+ fill: rgba(255, 255, 255, 0.18);
+}
+
+.area-black {
+ fill: rgba(20, 20, 30, 0.55);
+}
+
+.eval-line {
+ fill: none;
+ stroke: var(--nc-neon, #ff45c8);
+ stroke-width: 1.5;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.active-marker {
+ stroke: var(--nc-warning, #ffb13a);
+ stroke-width: 1.5;
+ stroke-dasharray: 3 3;
+ opacity: 0.8;
+}
+
+.empty {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+ padding: 12px 0;
+ font-family: var(--nc-mono, monospace);
+ letter-spacing: 0.06em;
+ text-align: center;
+}
diff --git a/src/app/components/eval-timeline/eval-timeline.component.html b/src/app/components/eval-timeline/eval-timeline.component.html
new file mode 100644
index 0000000..1589d21
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.html
@@ -0,0 +1,53 @@
+@if (points.length === 0) {
+ No evaluation data yet.
+} @else {
+
+
+
+}
diff --git a/src/app/components/eval-timeline/eval-timeline.component.ts b/src/app/components/eval-timeline/eval-timeline.component.ts
new file mode 100644
index 0000000..9099dd9
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.ts
@@ -0,0 +1,73 @@
+import { Component, Input, OnChanges } from '@angular/core';
+import { AnnotatedMove } from '../../models/analysis.models';
+
+interface TimelinePoint {
+ x: number;
+ y: number;
+ eval: number;
+ san: string;
+ plyIndex: number;
+}
+
+const CLAMP = 5; // clamp eval to ±5 pawns for display
+const HEIGHT = 80;
+const WIDTH = 600;
+
+@Component({
+ selector: 'app-eval-timeline',
+ standalone: true,
+ imports: [],
+ templateUrl: './eval-timeline.component.html',
+ styleUrl: './eval-timeline.component.css',
+})
+export class EvalTimelineComponent implements OnChanges {
+ @Input({ required: true }) moves: AnnotatedMove[] = [];
+ @Input() activePly: number | null = null;
+
+ points: TimelinePoint[] = [];
+ evalPolyline = '';
+ polylineWhite = '';
+ polylineBlack = '';
+ svgWidth = WIDTH;
+ svgHeight = HEIGHT;
+
+ ngOnChanges(): void {
+ this.buildChart();
+ }
+
+ activeX(): number | null {
+ if (this.activePly === null) return null;
+ const pt = this.points[this.activePly];
+ return pt ? pt.x : null;
+ }
+
+ private buildChart(): void {
+ if (this.moves.length === 0) {
+ this.points = [];
+ this.evalPolyline = '';
+ this.polylineWhite = '';
+ this.polylineBlack = '';
+ return;
+ }
+
+ const total = this.moves.length;
+ this.points = this.moves.map((m, i) => {
+ const evalValue = m.evalAfter ?? 0;
+ const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue));
+ const x = (i / Math.max(total - 1, 1)) * WIDTH;
+ // y=0 => white winning (top), y=HEIGHT => black winning (bottom)
+ const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT;
+ return { x, y, eval: evalValue, san: m.san, plyIndex: i };
+ });
+
+ const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
+ this.evalPolyline = coordStr;
+
+ const mid = HEIGHT / 2;
+ const first = this.points[0];
+ const last = this.points[this.points.length - 1];
+
+ this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`;
+ this.polylineBlack = this.polylineWhite;
+ }
+}
diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html
index 8522bd5..d859706 100644
--- a/src/app/components/toolbar/toolbar.component.html
+++ b/src/app/components/toolbar/toolbar.component.html
@@ -1,5 +1,4 @@