feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<div class="analysis-shell">
|
||||
<div class="page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="crumb" aria-label="Breadcrumb">
|
||||
<a routerLink="/" class="crumb-link">
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to lobby
|
||||
</a>
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb-current">Analysis</span>
|
||||
</nav>
|
||||
|
||||
<!-- Page header -->
|
||||
<header class="page-header">
|
||||
<div class="page-title">
|
||||
<h1>Chess Analysis</h1>
|
||||
<p class="page-subtitle">Analyse positions, games or custom PGN with the engine</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error -->
|
||||
@if (errorMessage) {
|
||||
<div class="state-error">{{ errorMessage }}</div>
|
||||
}
|
||||
|
||||
<!-- Input section -->
|
||||
<section class="input-section">
|
||||
<!-- Mode tabs -->
|
||||
<div class="mode-tabs" role="tablist" aria-label="Analysis input mode">
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'fen'"
|
||||
role="tab"
|
||||
(click)="setInputMode('fen')"
|
||||
>
|
||||
FEN
|
||||
</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'pgn'"
|
||||
role="tab"
|
||||
(click)="setInputMode('pgn')"
|
||||
>
|
||||
PGN
|
||||
</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
[class.active]="inputMode === 'game'"
|
||||
role="tab"
|
||||
(click)="setInputMode('game')"
|
||||
>
|
||||
Game ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FEN input -->
|
||||
@if (inputMode === 'fen') {
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="fen-input"
|
||||
type="text"
|
||||
class="text-input"
|
||||
placeholder="Paste FEN string here…"
|
||||
autocomplete="off"
|
||||
[(ngModel)]="fenInput"
|
||||
(keydown.enter)="loadFen()"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadFen()"
|
||||
[disabled]="!fenInput.trim()"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PGN input -->
|
||||
@if (inputMode === 'pgn') {
|
||||
<div class="pgn-col">
|
||||
<textarea
|
||||
id="pgn-input"
|
||||
class="text-area"
|
||||
rows="5"
|
||||
placeholder="Paste PGN here…"
|
||||
[(ngModel)]="pgnInput"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadPgn()"
|
||||
[disabled]="!pgnInput.trim() || loading"
|
||||
>
|
||||
@if (loading) {
|
||||
Loading…
|
||||
} @else {
|
||||
Import PGN
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Game ID input -->
|
||||
@if (inputMode === 'game') {
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="gameid-input"
|
||||
type="text"
|
||||
class="text-input"
|
||||
placeholder="Game ID"
|
||||
autocomplete="off"
|
||||
[(ngModel)]="fenInput"
|
||||
(keydown.enter)="loadGame(fenInput)"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
(click)="loadGame(fenInput)"
|
||||
[disabled]="!fenInput.trim() || loading"
|
||||
>
|
||||
@if (loading) {
|
||||
Loading…
|
||||
} @else {
|
||||
Load game
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Depth + analyse -->
|
||||
<div class="depth-row">
|
||||
<label class="depth-label" for="depth-input">Depth</label>
|
||||
<input
|
||||
id="depth-input"
|
||||
type="number"
|
||||
class="depth-input"
|
||||
min="1"
|
||||
max="18"
|
||||
[(ngModel)]="depth"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-analyse"
|
||||
type="button"
|
||||
(click)="runAnalysis()"
|
||||
[disabled]="analysing"
|
||||
>
|
||||
@if (analysing) {
|
||||
Analysing…
|
||||
} @else {
|
||||
Analyse
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main layout: board + sidebar -->
|
||||
<div class="layout">
|
||||
<!-- Board column -->
|
||||
<div class="board-col">
|
||||
<div class="board-wrap">
|
||||
<app-chess-board
|
||||
[fen]="displayFen"
|
||||
[selectedSquare]="null"
|
||||
[highlightedSquares]="[]"
|
||||
boardTheme="arabian"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation bar -->
|
||||
@if (fenHistory.length > 1) {
|
||||
<div class="nav-bar">
|
||||
<button class="icon-btn" title="First position" (click)="navigateHistory('first')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="11 17 6 12 11 7" />
|
||||
<polyline points="18 17 13 12 18 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Previous" (click)="navigateHistory('prev')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Next" (click)="navigateHistory('next')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Last position" (click)="navigateHistory('last')">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="13 17 18 12 13 7" />
|
||||
<polyline points="6 17 11 12 6 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Side column -->
|
||||
<aside class="side">
|
||||
<!-- Single position analysis result -->
|
||||
@if (positionAnalysis && !hasAnnotations) {
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Position Analysis</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="side-card-body eval-grid">
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Evaluation</span>
|
||||
<span
|
||||
class="eval-value"
|
||||
[class.positive]="positionAnalysis.eval > 0"
|
||||
[class.negative]="positionAnalysis.eval < 0"
|
||||
>
|
||||
{{ positionAnalysis.eval > 0 ? '+' : '' }}{{ positionAnalysis.eval.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Win chance</span>
|
||||
<span class="eval-value">{{ (positionAnalysis.winChance * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Best move</span>
|
||||
<span class="eval-value mono">{{ positionAnalysis.bestMove }}</span>
|
||||
</div>
|
||||
<div class="eval-row">
|
||||
<span class="eval-label">Depth</span>
|
||||
<span class="eval-value mono">{{ positionAnalysis.depth }}</span>
|
||||
</div>
|
||||
@if (positionAnalysis.continuations.length > 0) {
|
||||
<div class="eval-row eval-row--col">
|
||||
<span class="eval-label">Continuation</span>
|
||||
<span class="eval-value mono continuation">{{
|
||||
positionAnalysis.continuations.join(' ')
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<!-- Evaluation timeline -->
|
||||
@if (hasAnnotations) {
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Evaluation</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="side-card-body timeline-body">
|
||||
<app-eval-timeline [moves]="annotatedMoves" [activePly]="activePly" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Annotated moves -->
|
||||
<details class="side-card" open>
|
||||
<summary class="side-card-summary">
|
||||
<span class="side-card-title">Moves</span>
|
||||
<span class="side-card-meta">{{ annotatedMoves.length }} plies</span>
|
||||
<svg
|
||||
class="chev"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</summary>
|
||||
<app-annotated-move-list
|
||||
[moves]="annotatedMoves"
|
||||
[activePly]="activePly"
|
||||
(plySelected)="navigateToPly($event)"
|
||||
/>
|
||||
</details>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user