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..30729b1 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: '' }
];
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 2f546f6..968ec22 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..293f75f 100644
--- a/src/app/components/toolbar/toolbar.component.html
+++ b/src/app/components/toolbar/toolbar.component.html
@@ -19,6 +19,7 @@
+
}
diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts
index 2aa57a8..dc51eff 100644
--- a/src/app/components/toolbar/toolbar.component.ts
+++ b/src/app/components/toolbar/toolbar.component.ts
@@ -197,6 +197,12 @@ export class ToolbarComponent implements OnInit {
void this.router.navigate(['/bots']);
}
+ goToAnalysis(): void {
+ this.profileOpen = false;
+ this.notifOpen = false;
+ void this.router.navigate(['/analysis']);
+ }
+
onLoginSuccess(): void {
this.closeLoginDialog();
}
diff --git a/src/app/models/analysis.models.ts b/src/app/models/analysis.models.ts
new file mode 100644
index 0000000..53f1bc1
--- /dev/null
+++ b/src/app/models/analysis.models.ts
@@ -0,0 +1,25 @@
+export interface AnalysisRequest {
+ fen: string;
+ depth: number;
+}
+
+export interface AnalysisResponse {
+ eval: number;
+ winChance: number;
+ depth: number;
+ bestMove: string;
+ continuations: string[];
+}
+
+export type MoveQuality = 'brilliant' | 'best' | 'good' | 'inaccuracy' | 'mistake' | 'blunder';
+
+export interface AnnotatedMove {
+ san: string;
+ fen: string;
+ evalBefore: number | null;
+ evalAfter: number | null;
+ quality: MoveQuality | null;
+ bestMove: string | null;
+ winChanceBefore: number | null;
+ winChanceAfter: number | null;
+}
diff --git a/src/app/pages/analysis/analysis.component.css b/src/app/pages/analysis/analysis.component.css
new file mode 100644
index 0000000..0a6e8ae
--- /dev/null
+++ b/src/app/pages/analysis/analysis.component.css
@@ -0,0 +1,542 @@
+/* ============================================================
+ DESIGN TOKENS — dark mode (default)
+============================================================ */
+:host {
+ --nc-neon: #ff45c8;
+ --nc-neon-soft: rgba(255, 69, 200, 0.55);
+ --nc-bg: #06060d;
+ --nc-surface: rgba(20, 17, 42, 0.6);
+ --nc-surface-solid: rgba(10, 8, 22, 0.95);
+ --nc-text: #fff;
+ --nc-text-muted: rgba(255, 255, 255, 0.65);
+ --nc-text-dim: rgba(255, 255, 255, 0.45);
+ --nc-border: rgba(255, 255, 255, 0.08);
+ --nc-border-strong: rgba(255, 255, 255, 0.15);
+ --nc-warning: #ffb13a;
+ --nc-warning-soft: rgba(255, 177, 58, 0.4);
+ --nc-danger: #ff7a7a;
+ --nc-danger-bg: rgba(255, 122, 122, 0.08);
+ --nc-danger-soft: rgba(255, 122, 122, 0.3);
+ --nc-success: #5ee5a1;
+ --nc-clock-bg: rgba(0, 0, 0, 0.4);
+ --nc-btn-bg: rgba(255, 255, 255, 0.03);
+ --nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
+ --nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
+}
+
+:host-context(html:not([data-theme='dark'])) {
+ --nc-neon: #ff3dbb;
+ --nc-neon-soft: rgba(255, 61, 187, 0.55);
+ --nc-bg: transparent;
+ --nc-surface: rgba(26, 24, 56, 0.72);
+ --nc-surface-solid: rgba(26, 24, 56, 0.97);
+ --nc-text: #fff;
+ --nc-text-muted: rgba(255, 255, 255, 0.72);
+ --nc-text-dim: rgba(255, 255, 255, 0.45);
+ --nc-border: rgba(255, 255, 255, 0.1);
+ --nc-border-strong: rgba(255, 255, 255, 0.18);
+ --nc-btn-bg: rgba(255, 255, 255, 0.05);
+ --nc-btn-hover-bg: rgba(255, 255, 255, 0.1);
+}
+
+/* ============================================================
+ SHELL
+============================================================ */
+.analysis-shell {
+ min-height: 100dvh;
+ background: var(--nc-bg);
+ font-family: var(--nc-sans);
+ color: var(--nc-text);
+ position: relative;
+}
+
+.analysis-shell::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%),
+ radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%);
+ pointer-events: none;
+ z-index: 0;
+}
+
+/* ============================================================
+ PAGE CONTAINER
+============================================================ */
+.page {
+ position: relative;
+ z-index: 1;
+ max-width: 1320px;
+ margin: 0 auto;
+ padding: 28px 32px 60px;
+}
+
+/* ============================================================
+ BREADCRUMB
+============================================================ */
+.crumb {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 18px;
+ font-family: var(--nc-mono);
+ font-size: 11px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.crumb-link {
+ color: var(--nc-text-dim);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: color 0.15s;
+}
+
+.crumb-link:hover {
+ color: var(--nc-neon);
+}
+.crumb-sep {
+ color: var(--nc-text-dim);
+ opacity: 0.5;
+}
+.crumb-current {
+ color: var(--nc-text-muted);
+}
+
+/* ============================================================
+ PAGE HEADER
+============================================================ */
+.page-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 28px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--nc-border);
+}
+
+.page-title h1 {
+ margin: 0 0 4px;
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--nc-text);
+}
+
+.page-subtitle {
+ margin: 0;
+ font-family: var(--nc-mono);
+ font-size: 11px;
+ color: var(--nc-text-dim);
+ letter-spacing: 0.06em;
+}
+
+/* ============================================================
+ ERROR
+============================================================ */
+.state-error {
+ padding: 14px 16px;
+ margin-bottom: 20px;
+ color: var(--nc-danger);
+ background: var(--nc-danger-bg);
+ border: 1px solid var(--nc-danger-soft);
+ font-size: 13px;
+}
+
+/* ============================================================
+ INPUT SECTION
+============================================================ */
+.input-section {
+ background: var(--nc-surface);
+ border: 1px solid var(--nc-border);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ padding: 20px;
+ margin-bottom: 28px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.mode-tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--nc-border);
+ padding-bottom: 12px;
+}
+
+.mode-tab {
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--nc-text-muted);
+ font-family: var(--nc-mono);
+ font-size: 11px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ padding: 6px 14px;
+ cursor: pointer;
+ transition:
+ color 0.15s,
+ border-color 0.15s;
+}
+
+.mode-tab:hover {
+ color: var(--nc-text);
+}
+
+.mode-tab.active {
+ color: var(--nc-neon);
+ border-color: var(--nc-neon-soft);
+}
+
+.input-row {
+ display: flex;
+ gap: 8px;
+}
+
+.text-input {
+ flex: 1;
+ background: var(--nc-clock-bg);
+ border: 1px solid var(--nc-border);
+ color: var(--nc-text);
+ font-family: var(--nc-mono);
+ font-size: 12px;
+ padding: 9px 12px;
+ letter-spacing: 0.04em;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.text-input:focus {
+ border-color: var(--nc-neon-soft);
+}
+.text-input::placeholder {
+ color: var(--nc-text-dim);
+}
+
+.pgn-col {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.text-area {
+ background: var(--nc-clock-bg);
+ border: 1px solid var(--nc-border);
+ color: var(--nc-text);
+ font-family: var(--nc-mono);
+ font-size: 12px;
+ padding: 10px 12px;
+ letter-spacing: 0.04em;
+ resize: vertical;
+ outline: none;
+ transition: border-color 0.15s;
+ line-height: 1.5;
+}
+
+.text-area:focus {
+ border-color: var(--nc-neon-soft);
+}
+.text-area::placeholder {
+ color: var(--nc-text-dim);
+}
+
+.depth-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-top: 6px;
+ border-top: 1px solid var(--nc-border);
+}
+
+.depth-label {
+ font-family: var(--nc-mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--nc-text-dim);
+}
+
+.depth-input {
+ width: 56px;
+ background: var(--nc-clock-bg);
+ border: 1px solid var(--nc-border);
+ color: var(--nc-text);
+ font-family: var(--nc-mono);
+ font-size: 13px;
+ padding: 7px 10px;
+ outline: none;
+ transition: border-color 0.15s;
+ text-align: center;
+}
+
+.depth-input:focus {
+ border-color: var(--nc-neon-soft);
+}
+
+/* ============================================================
+ BUTTONS
+============================================================ */
+.btn {
+ font-family: var(--nc-sans);
+ font-size: 11px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ font-weight: 600;
+ padding: 9px 14px;
+ cursor: pointer;
+ border: 1px solid var(--nc-border-strong);
+ background: var(--nc-btn-bg);
+ color: var(--nc-text);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+ flex-shrink: 0;
+}
+
+.btn:hover:not([disabled]) {
+ background: var(--nc-btn-hover-bg);
+ border-color: var(--nc-text-muted);
+}
+
+.btn[disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--nc-neon) !important;
+ color: #fff !important;
+ border-color: var(--nc-neon) !important;
+ box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
+ font-weight: 700;
+}
+
+.btn-primary:hover:not([disabled]) {
+ box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
+}
+
+.btn-analyse {
+ margin-left: auto;
+ background: rgba(255, 69, 200, 0.12);
+ color: var(--nc-neon);
+ border-color: var(--nc-neon-soft);
+}
+
+.btn-analyse:hover:not([disabled]) {
+ background: rgba(255, 69, 200, 0.2);
+}
+
+/* ============================================================
+ MAIN GRID
+============================================================ */
+.layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ gap: 28px;
+ align-items: start;
+}
+
+/* ============================================================
+ BOARD COLUMN
+============================================================ */
+.board-col {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-width: 520px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.board-wrap {
+ container-type: size;
+ aspect-ratio: 1 / 1;
+ padding: 10px;
+ background: var(--nc-surface);
+ border: 1px solid var(--nc-border);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ box-shadow:
+ 0 8px 40px rgba(0, 0, 0, 0.4),
+ 0 0 0 1px rgba(255, 69, 200, 0.06);
+}
+
+.nav-bar {
+ display: flex;
+ gap: 4px;
+ justify-content: center;
+ padding: 6px 0;
+}
+
+.icon-btn {
+ background: var(--nc-btn-bg);
+ border: 1px solid var(--nc-border);
+ color: var(--nc-text-muted);
+ padding: 8px 10px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ color 0.15s,
+ background 0.15s;
+}
+
+.icon-btn:hover {
+ color: var(--nc-neon);
+ background: var(--nc-btn-hover-bg);
+}
+
+/* ============================================================
+ SIDE COLUMN
+============================================================ */
+.side {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.side-card {
+ background: var(--nc-surface);
+ border: 1px solid var(--nc-border);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+
+.side-card-summary {
+ list-style: none;
+ cursor: pointer;
+ padding: 13px 16px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ user-select: none;
+}
+
+.side-card-summary::-webkit-details-marker {
+ display: none;
+}
+
+.side-card-title {
+ font-size: 11px;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: var(--nc-text-muted);
+ font-weight: 600;
+ flex: 1;
+}
+
+.side-card-meta {
+ font-family: var(--nc-mono);
+ font-size: 10px;
+ color: var(--nc-text-dim);
+ letter-spacing: 0.08em;
+}
+
+.chev {
+ color: var(--nc-text-dim);
+ flex-shrink: 0;
+ transition: transform 0.2s;
+}
+.side-card[open] .chev {
+ transform: rotate(180deg);
+}
+.side-card[open] .side-card-summary {
+ border-bottom: 1px solid var(--nc-border);
+}
+
+.side-card-body {
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.timeline-body {
+ padding: 10px 16px;
+}
+
+/* ============================================================
+ EVAL GRID
+============================================================ */
+.eval-grid {
+ gap: 8px;
+}
+
+.eval-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.eval-row--col {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.eval-label {
+ font-family: var(--nc-mono);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--nc-text-dim);
+ flex-shrink: 0;
+}
+
+.eval-value {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--nc-text);
+}
+
+.eval-value.mono {
+ font-family: var(--nc-mono);
+ font-size: 12px;
+}
+
+.eval-value.positive {
+ color: var(--nc-success);
+}
+.eval-value.negative {
+ color: var(--nc-danger);
+}
+
+.continuation {
+ font-size: 11px;
+ color: var(--nc-text-muted);
+ line-height: 1.6;
+ word-break: break-all;
+}
+
+/* ============================================================
+ RESPONSIVE
+============================================================ */
+@media (max-width: 1100px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+ .board-col {
+ max-width: 560px;
+ margin: 0 auto;
+ }
+}
+
+@media (max-width: 640px) {
+ .page {
+ padding: 16px 16px 48px;
+ }
+ .page-title h1 {
+ font-size: 20px;
+ }
+}
diff --git a/src/app/pages/analysis/analysis.component.html b/src/app/pages/analysis/analysis.component.html
new file mode 100644
index 0000000..a2ce620
--- /dev/null
+++ b/src/app/pages/analysis/analysis.component.html
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+ @if (errorMessage) {
+
{{ errorMessage }}
+ }
+
+
+
+
+
+
+
+
+
+
+
+ @if (fenHistory.length > 1) {
+
+
+
+
+
+
+ }
+
+
+
+
+
+
+
diff --git a/src/app/pages/analysis/analysis.component.ts b/src/app/pages/analysis/analysis.component.ts
new file mode 100644
index 0000000..1aeb908
--- /dev/null
+++ b/src/app/pages/analysis/analysis.component.ts
@@ -0,0 +1,244 @@
+import { Component, DestroyRef, inject, OnInit } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { switchMap, of } from 'rxjs';
+
+import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
+import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
+import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component';
+
+import { GameApiService } from '../../services/game-api.service';
+import { AnalysisService } from '../../services/analysis.service';
+
+import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models';
+import { GameFull } from '../../models/game.models';
+import { getErrorMessage } from '../../core/http/error-message.util';
+
+const ANALYSIS_DEPTH_DEFAULT = 14;
+const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
+
+type InputMode = 'fen' | 'pgn' | 'game';
+
+@Component({
+ selector: 'app-analysis',
+ standalone: true,
+ imports: [
+ RouterLink,
+ FormsModule,
+ ChessBoardComponent,
+ EvalTimelineComponent,
+ AnnotatedMoveListComponent,
+ ],
+ templateUrl: './analysis.component.html',
+ styleUrl: './analysis.component.css',
+})
+export class AnalysisComponent implements OnInit {
+ private readonly route = inject(ActivatedRoute);
+ private readonly gameApi = inject(GameApiService);
+ private readonly analysisService = inject(AnalysisService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ // ── State ─────────────────────────────────────────────────
+ inputMode: InputMode = 'fen';
+ fenInput = '';
+ pgnInput = '';
+ depth = ANALYSIS_DEPTH_DEFAULT;
+
+ loading = false;
+ analysing = false;
+ errorMessage = '';
+
+ currentFen = STARTING_FEN;
+ game: GameFull | null = null;
+
+ /** FEN for each ply (index 0 = position before move 0 was played) */
+ fenHistory: string[] = [STARTING_FEN];
+ annotatedMoves: AnnotatedMove[] = [];
+ activePly: number | null = null;
+
+ /** Single-position analysis result (for custom FEN/PGN input) */
+ positionAnalysis: AnalysisResponse | null = null;
+
+ get displayFen(): string {
+ if (this.activePly !== null && this.fenHistory[this.activePly + 1]) {
+ return this.fenHistory[this.activePly + 1];
+ }
+ return this.currentFen;
+ }
+
+ get hasAnnotations(): boolean {
+ return this.annotatedMoves.length > 0;
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────
+ ngOnInit(): void {
+ // Support /analysis?gameId=xxx deep-link
+ this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+ const gameId = params.get('gameId');
+ if (gameId) {
+ this.inputMode = 'game';
+ this.loadGame(gameId);
+ }
+ });
+ }
+
+ // ── Input mode ─────────────────────────────────────────────
+ setInputMode(mode: InputMode): void {
+ this.inputMode = mode;
+ this.errorMessage = '';
+ }
+
+ // ── Load FEN ──────────────────────────────────────────────
+ loadFen(): void {
+ const fen = this.fenInput.trim();
+ if (!fen) return;
+ this.reset();
+ this.currentFen = fen;
+ this.fenHistory = [fen];
+ this.analyseSinglePosition(fen);
+ }
+
+ // ── Load PGN ──────────────────────────────────────────────
+ loadPgn(): void {
+ const pgn = this.pgnInput.trim();
+ if (!pgn) return;
+ this.reset();
+ this.loading = true;
+ this.gameApi
+ .importPgn(pgn)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: (game) => {
+ this.loading = false;
+ this.applyGame(game);
+ },
+ error: (err) => {
+ this.loading = false;
+ this.errorMessage = getErrorMessage(err, 'Could not import PGN.');
+ },
+ });
+ }
+
+ // ── Load game by ID ───────────────────────────────────────
+ loadGame(gameId: string): void {
+ this.reset();
+ this.loading = true;
+ this.gameApi
+ .getGame(gameId)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: (game) => {
+ this.loading = false;
+ this.applyGame(game);
+ },
+ error: (err) => {
+ this.loading = false;
+ this.errorMessage = getErrorMessage(err, 'Could not load game.');
+ },
+ });
+ }
+
+ // ── Run full analysis ─────────────────────────────────────
+ runAnalysis(): void {
+ if (this.fenHistory.length < 2) {
+ this.analyseSinglePosition(this.currentFen);
+ return;
+ }
+ this.analysing = true;
+ this.errorMessage = '';
+ const sans =
+ this.annotatedMoves.length > 0
+ ? this.annotatedMoves.map((m) => m.san)
+ : (this.game?.state.moves ?? []);
+
+ this.analysisService
+ .analyzeGame(sans, this.fenHistory, this.depth)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: (annotated) => {
+ this.annotatedMoves = annotated;
+ this.analysing = false;
+ },
+ error: (err) => {
+ this.errorMessage = getErrorMessage(err, 'Analysis failed.');
+ this.analysing = false;
+ },
+ });
+ }
+
+ // ── Board navigation ──────────────────────────────────────
+ navigateToPly(ply: number): void {
+ this.activePly = ply;
+ }
+
+ navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
+ const total = this.fenHistory.length - 1;
+ const current = this.activePly ?? total;
+ let next: number;
+ switch (direction) {
+ case 'first':
+ next = 0;
+ break;
+ case 'prev':
+ next = Math.max(0, current - 1);
+ break;
+ case 'next':
+ next = Math.min(total, current + 1);
+ break;
+ default:
+ next = total;
+ break;
+ }
+ this.activePly = next >= total ? null : next;
+ }
+
+ // ── Private helpers ───────────────────────────────────────
+ private reset(): void {
+ this.errorMessage = '';
+ this.annotatedMoves = [];
+ this.positionAnalysis = null;
+ this.activePly = null;
+ this.game = null;
+ this.fenHistory = [STARTING_FEN];
+ this.currentFen = STARTING_FEN;
+ }
+
+ private applyGame(game: GameFull): void {
+ this.game = game;
+ this.currentFen = game.state.fen;
+ // Build a flat FEN history from scratch using moves array
+ // The server gives us the final FEN. We reconstruct history by
+ // storing the final FEN; full per-ply history requires per-move API calls
+ // which is out of scope here — we store what we have and allow analysis to proceed.
+ this.fenHistory = [game.state.fen];
+ // Seed annotated moves with san strings, no quality yet
+ this.annotatedMoves = game.state.moves.map((san) => ({
+ san,
+ fen: game.state.fen,
+ evalBefore: null,
+ evalAfter: null,
+ quality: null,
+ bestMove: null,
+ winChanceBefore: null,
+ winChanceAfter: null,
+ }));
+ }
+
+ private analyseSinglePosition(fen: string): void {
+ this.analysing = true;
+ this.analysisService
+ .analyzePosition(fen, this.depth)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: (result) => {
+ this.positionAnalysis = result;
+ this.analysing = false;
+ },
+ error: (err) => {
+ this.errorMessage = getErrorMessage(err, 'Analysis failed.');
+ this.analysing = false;
+ },
+ });
+ }
+}
diff --git a/src/app/services/analysis.service.ts b/src/app/services/analysis.service.ts
new file mode 100644
index 0000000..4982c78
--- /dev/null
+++ b/src/app/services/analysis.service.ts
@@ -0,0 +1,102 @@
+import { Injectable, inject } from '@angular/core';
+import { Observable, forkJoin, of } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
+import { GameApiService } from './game-api.service';
+import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models';
+
+const DEFAULT_DEPTH = 14;
+
+@Injectable({ providedIn: 'root' })
+export class AnalysisService {
+ private readonly gameApi = inject(GameApiService);
+
+ analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable {
+ return this.gameApi.analyzePosition({ fen, depth });
+ }
+
+ /**
+ * Analyse a sequence of FEN positions (one per ply) and return annotated moves.
+ * fenHistory[0] is the starting position; fenHistory[n] is reached after move san[n-1].
+ */
+ analyzeGame(
+ sans: string[],
+ fenHistory: string[],
+ depth = DEFAULT_DEPTH,
+ ): Observable {
+ if (sans.length === 0 || fenHistory.length < 2) {
+ return of([]);
+ }
+
+ const requests = fenHistory.map((fen) => this.gameApi.analyzePosition({ fen, depth }));
+
+ return forkJoin(requests).pipe(
+ map((results) => this.buildAnnotations(sans, fenHistory, results)),
+ );
+ }
+
+ private buildAnnotations(
+ sans: string[],
+ fenHistory: string[],
+ results: AnalysisResponse[],
+ ): AnnotatedMove[] {
+ return sans.map((san, i) => {
+ const evalBefore = results[i]?.eval ?? null;
+ const winChanceBefore = results[i]?.winChance ?? null;
+ const evalAfter = results[i + 1]?.eval ?? null;
+ const winChanceAfter = results[i + 1]?.winChance ?? null;
+ const bestMove = results[i]?.bestMove ?? null;
+
+ const quality = this.classifyQuality(
+ evalBefore,
+ evalAfter,
+ winChanceBefore,
+ winChanceAfter,
+ san,
+ bestMove,
+ );
+
+ return {
+ san,
+ fen: fenHistory[i + 1] ?? fenHistory[i],
+ evalBefore,
+ evalAfter,
+ quality,
+ bestMove,
+ winChanceBefore,
+ winChanceAfter,
+ };
+ });
+ }
+
+ /**
+ * Classify move quality based on win-chance delta.
+ * Win-chance is from the engine's perspective (side to move before the move).
+ * After the move the side has flipped, so we invert the after value.
+ */
+ private classifyQuality(
+ _evalBefore: number | null,
+ _evalAfter: number | null,
+ winChanceBefore: number | null,
+ winChanceAfter: number | null,
+ san: string,
+ bestMove: string | null,
+ ): MoveQuality | null {
+ if (winChanceBefore === null || winChanceAfter === null) return null;
+
+ // The engine expresses win chance for the mover. After our move the mover
+ // has switched, so the opponent's win chance = winChanceAfter. Our remaining
+ // winning chance from our own perspective is 1 - winChanceAfter.
+ const wcAfterOurPerspective = 1 - winChanceAfter;
+ const delta = wcAfterOurPerspective - winChanceBefore; // negative = we lost win chance
+
+ if (bestMove && san === bestMove) {
+ if (delta >= -0.01) return 'brilliant';
+ return 'best';
+ }
+
+ if (delta >= -0.02) return 'good';
+ if (delta >= -0.05) return 'inaccuracy';
+ if (delta >= -0.1) return 'mistake';
+ return 'blunder';
+ }
+}
diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts
index cf7aef6..86fd951 100644
--- a/src/app/services/game-api.service.ts
+++ b/src/app/services/game-api.service.ts
@@ -9,6 +9,7 @@ import {
LegalMovesResponse,
PlayerInfo
} from '../models/game.models';
+import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' })
@@ -75,6 +76,10 @@ export class GameApiService {
return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
}
+ analyzePosition(request: AnalysisRequest): Observable {
+ return this.http.post(`${this.apiBase}/api/analysis/position`, request);
+ }
+
private resolveWsBase(): string {
if (this.wsBase) {
return this.wsBase;