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) { + + +
+ {{ 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 (inputMode === 'fen') { +
+ + +
+ } + + + @if (inputMode === 'pgn') { +
+ + +
+ } + + + @if (inputMode === 'game') { +
+ + +
+ } + + +
+ + + +
+
+ + +
+ +
+
+ +
+ + + @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;