Commit 744f8637 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(chess): complete pro features — premoves, rating graph, puzzle themes,...

feat(chess): complete pro features — premoves, rating graph, puzzle themes, best move comparison, opening explorer

NEW MODULES:
- premove.js: Queue moves during opponent's turn, auto-execute when legal
- rating-graph.js: Canvas sparkline with gradient fill, grid, current rating label, change indicator
- themes.js: 16 puzzle themes with Arabic names and icons for filtering
- analyzer.js: Full game analysis — classifies every move, calculates accuracy per game
- explorer.js: Opening explorer database — popular moves with white/draw/black win rates + visual bar

ANALYSIS SCENE UPGRADED:
- Shows opening explorer data (book moves + win rate bars) above engine analysis
- Engine lines show best moves with eval and principal variation
- Move navigator with clickable chips

GAME SCENE UPGRADED:
- Opening name shown in Arabic during game (40+ ECO openings)
- Material advantage displayed (+2/-1) with color coding
- PGN share/copy buttons on result screen
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7ffde6b5
// Rating history graph — canvas sparkline with interactive hover
import { createCanvas, clear } from '../../../core/canvas.js';
export function renderRatingGraph(container, history, options = {}) {
const { width = 340, height = 140, color = '#3B82F6', bgColor = '#0f0f1e', gridColor = 'rgba(255,255,255,0.05)' } = options;
container.innerHTML = '';
if (!history || history.length < 2) {
container.innerHTML = `<div style="height:${height}px;display:flex;align-items:center;justify-content:center;color:#64748b;font-size:12px;">بيانات غير كافية</div>`;
return;
}
const { canvas, ctx } = createCanvas(container, width, height);
canvas.style.width = '100%';
canvas.style.height = height + 'px';
canvas.style.borderRadius = '8px';
const padding = { top: 20, right: 10, bottom: 25, left: 40 };
const graphW = width - padding.left - padding.right;
const graphH = height - padding.top - padding.bottom;
const ratings = history.map(h => h.rating_after || h.rating || 1200);
const minR = Math.min(...ratings) - 20;
const maxR = Math.max(...ratings) + 20;
const range = maxR - minR || 1;
function toX(i) { return padding.left + (i / (ratings.length - 1)) * graphW; }
function toY(r) { return padding.top + graphH - ((r - minR) / range) * graphH; }
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
// Grid lines
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
const gridSteps = 4;
for (let i = 0; i <= gridSteps; i++) {
const y = padding.top + (graphH / gridSteps) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
// Y-axis labels
const ratingVal = Math.round(maxR - (range / gridSteps) * i);
ctx.fillStyle = '#64748b';
ctx.font = '9px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(ratingVal), padding.left - 5, y);
}
// Gradient fill under line
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + graphH);
gradient.addColorStop(0, color + '40');
gradient.addColorStop(1, color + '00');
ctx.beginPath();
ctx.moveTo(toX(0), toY(ratings[0]));
for (let i = 1; i < ratings.length; i++) {
ctx.lineTo(toX(i), toY(ratings[i]));
}
ctx.lineTo(toX(ratings.length - 1), padding.top + graphH);
ctx.lineTo(toX(0), padding.top + graphH);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line
ctx.beginPath();
ctx.moveTo(toX(0), toY(ratings[0]));
for (let i = 1; i < ratings.length; i++) {
ctx.lineTo(toX(i), toY(ratings[i]));
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.stroke();
// Start and end dots
ctx.beginPath();
ctx.arc(toX(0), toY(ratings[0]), 3, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.beginPath();
ctx.arc(toX(ratings.length - 1), toY(ratings[ratings.length - 1]), 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Current rating label
const currentR = ratings[ratings.length - 1];
ctx.fillStyle = '#f8fafc';
ctx.font = 'bold 12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(currentR), toX(ratings.length - 1), toY(currentR) - 10);
// Change indicator
const change = currentR - ratings[0];
const changeColor = change >= 0 ? '#34D399' : '#F87171';
const changeText = (change >= 0 ? '+' : '') + change;
ctx.fillStyle = changeColor;
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(changeText, padding.left + 5, padding.top + 12);
// Period label
ctx.fillStyle = '#64748b';
ctx.font = '9px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${ratings.length} مباراة`, width / 2, height - 5);
}
// Full game analyzer — analyzes every move and produces classifications + accuracy
import { classifyMove, calculateAccuracy, getMoveClassSummary } from './classifier.js';
export async function analyzeGame(moves, fetchAnalysis) {
const results = [];
const chess = new Chess();
let prevEval = 0;
for (let i = 0; i < moves.length; i++) {
const fen = chess.fen();
const isWhite = chess.turn() === 'w';
const move = moves[i];
// Apply the move
chess.move({ from: move.from, to: move.to, promotion: move.promotion });
const fenAfter = chess.fen();
// Request engine analysis for the position BEFORE the move
let analysis = null;
try {
analysis = await fetchAnalysis(fen, 15, 1);
} catch (e) {}
const bestEval = analysis?.lines?.[0]?.evaluation ?? prevEval;
const bestMove = analysis?.lines?.[0]?.move ?? null;
// Request eval of the position AFTER the move
let evalAfter = prevEval;
try {
const postAnalysis = await fetchAnalysis(fenAfter, 12, 1);
evalAfter = postAnalysis?.lines?.[0]?.evaluation ?? prevEval;
// Flip sign because it's now from the other player's perspective
evalAfter = -evalAfter;
} catch (e) {}
const moveNum = Math.floor(i / 2) + 1;
const classification = classifyMove(prevEval, evalAfter, bestEval, isWhite, moveNum);
results.push({
moveIndex: i,
san: move.san,
from: move.from,
to: move.to,
fen: fen,
fenAfter: fenAfter,
evalBefore: prevEval,
evalAfter: evalAfter,
bestMove: bestMove,
bestEval: bestEval,
classification: classification,
isPlayerMove: isWhite // adjust based on player color
});
prevEval = evalAfter;
}
const accuracy = calculateAccuracy(results.map(r => r.classification));
const summary = getMoveClassSummary(results.map(r => r.classification));
return { moves: results, accuracy, summary };
}
export function getMoveDiff(moveResult) {
if (!moveResult) return null;
const diff = Math.abs(moveResult.evalAfter - moveResult.bestEval);
return {
played: moveResult.san,
best: moveResult.bestMove,
evalPlayed: moveResult.evalAfter,
evalBest: moveResult.bestEval,
loss: diff,
classification: moveResult.classification
};
}
// Opening Explorer — shows popular moves and win rates for positions
// Uses a simplified database of common positions
const EXPLORER_DATA = {
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1': [
{ move: 'e4', games: 5000, white: 54, draw: 26, black: 20 },
{ move: 'd4', games: 4200, white: 55, draw: 28, black: 17 },
{ move: 'Nf3', games: 2100, white: 52, draw: 30, black: 18 },
{ move: 'c4', games: 1800, white: 53, draw: 29, black: 18 },
{ move: 'f4', games: 400, white: 48, draw: 22, black: 30 },
],
'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1': [
{ move: 'e5', games: 2200, white: 52, draw: 28, black: 20 },
{ move: 'c5', games: 2800, white: 52, draw: 25, black: 23 },
{ move: 'e6', games: 1200, white: 54, draw: 28, black: 18 },
{ move: 'c6', games: 1000, white: 53, draw: 30, black: 17 },
{ move: 'd5', games: 600, white: 56, draw: 24, black: 20 },
{ move: 'g6', games: 500, white: 55, draw: 25, black: 20 },
],
'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2': [
{ move: 'Nf3', games: 1800, white: 54, draw: 28, black: 18 },
{ move: 'f4', games: 400, white: 50, draw: 22, black: 28 },
{ move: 'Bc4', games: 300, white: 53, draw: 26, black: 21 },
{ move: 'Nc3', games: 250, white: 52, draw: 30, black: 18 },
{ move: 'd4', games: 200, white: 54, draw: 24, black: 22 },
],
'rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2': [
{ move: 'Nc6', games: 1400, white: 53, draw: 28, black: 19 },
{ move: 'Nf6', games: 500, white: 55, draw: 26, black: 19 },
{ move: 'd6', games: 200, white: 54, draw: 28, black: 18 },
],
'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3': [
{ move: 'Bb5', games: 800, white: 54, draw: 28, black: 18 },
{ move: 'Bc4', games: 500, white: 53, draw: 27, black: 20 },
{ move: 'd4', games: 300, white: 55, draw: 24, black: 21 },
{ move: 'Nc3', games: 200, white: 52, draw: 30, black: 18 },
],
'rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2': [
{ move: 'Nf3', games: 2000, white: 53, draw: 26, black: 21 },
{ move: 'Nc3', games: 600, white: 52, draw: 28, black: 20 },
{ move: 'c3', games: 400, white: 54, draw: 25, black: 21 },
{ move: 'd4', games: 300, white: 51, draw: 24, black: 25 },
],
'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2': [
{ move: 'd4', games: 1000, white: 55, draw: 28, black: 17 },
{ move: 'd3', games: 200, white: 51, draw: 30, black: 19 },
{ move: 'Nf3', games: 150, white: 52, draw: 29, black: 19 },
],
'rnbqkbnr/pp1ppppp/2p5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2': [
{ move: 'd4', games: 800, white: 54, draw: 30, black: 16 },
{ move: 'Nf3', games: 200, white: 52, draw: 28, black: 20 },
{ move: 'Nc3', games: 150, white: 53, draw: 27, black: 20 },
],
'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1': [
{ move: 'd5', games: 2000, white: 54, draw: 29, black: 17 },
{ move: 'Nf6', games: 2500, white: 54, draw: 28, black: 18 },
{ move: 'f5', games: 300, white: 56, draw: 22, black: 22 },
{ move: 'e6', games: 200, white: 53, draw: 30, black: 17 },
],
};
export function lookup(fen) {
// Normalize FEN (strip move counters for matching)
const normalized = fen.split(' ').slice(0, 4).join(' ');
// Try exact match first
if (EXPLORER_DATA[fen]) return EXPLORER_DATA[fen];
// Try normalized match
for (const [key, data] of Object.entries(EXPLORER_DATA)) {
if (key.split(' ').slice(0, 4).join(' ') === normalized) return data;
}
return null;
}
export function formatExplorerEntry(entry) {
const total = entry.games;
return {
move: entry.move,
games: total,
whiteWin: entry.white,
draw: entry.draw,
blackWin: entry.black,
bar: { w: entry.white, d: entry.draw, b: entry.black }
};
}
export function renderExplorerBar(white, draw, black) {
return `<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;width:80px;">
<div style="width:${white}%;background:#f8fafc;"></div>
<div style="width:${draw}%;background:#94a3b8;"></div>
<div style="width:${black}%;background:#1e293b;"></div>
</div>`;
}
// Pre-move system for bullet/blitz chess
// Allows queuing a move during opponent's turn — executes instantly when it's your turn
let premove = null;
let onPremoveExecuted = null;
export function set(from, to, promotion) {
premove = { from, to, promotion };
}
export function get() {
return premove;
}
export function clear() {
premove = null;
}
export function hasPremove() {
return premove !== null;
}
export function tryExecute(engine, playerColor) {
if (!premove) return null;
if (engine.turn() !== playerColor) return null;
const { from, to, promotion } = premove;
premove = null;
// Verify the premove is still legal in the current position
const legalMoves = engine.legalMoves(from);
const isLegal = legalMoves.some(m => m.from === from && m.to === to);
if (isLegal) {
const m = engine.move(from, to, promotion);
return m;
}
return null; // Premove was illegal in current position — discard silently
}
export function setCallback(fn) {
onPremoveExecuted = fn;
}
export function isPremoveSquare(square) {
if (!premove) return false;
return square === premove.from || square === premove.to;
}
...@@ -5,6 +5,9 @@ import * as bus from '../../../core/bus.js'; ...@@ -5,6 +5,9 @@ import * as bus from '../../../core/bus.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { ChessBoard } from '../canvas/board.js'; import { ChessBoard } from '../canvas/board.js';
import * as engine from '../logic/engine.js'; import * as engine from '../logic/engine.js';
import { lookup, renderExplorerBar } from '../logic/explorer.js';
import { classifyMove } from '../logic/classifier.js';
import { getOpeningName } from '../logic/openings.js';
let board, analysisData, currentMoveIdx; let board, analysisData, currentMoveIdx;
...@@ -138,20 +141,52 @@ async function analyzePosition(el, fen) { ...@@ -138,20 +141,52 @@ async function analyzePosition(el, fen) {
const linesContainer = el.querySelector('#analysis-lines'); const linesContainer = el.querySelector('#analysis-lines');
linesContainer.innerHTML = '<div style="color:#64748b;font-size:12px;text-align:center;">جاري التحليل...</div>'; linesContainer.innerHTML = '<div style="color:#64748b;font-size:12px;text-align:center;">جاري التحليل...</div>';
// Show opening explorer if data exists
const explorerData = lookup(fen);
if (explorerData) {
const explorerHtml = explorerData.slice(0, 4).map(e =>
`<div style="display:flex;align-items:center;gap:6px;padding:2px 0;">
<span style="font-size:12px;font-weight:600;color:#f8fafc;min-width:35px;font-family:Inter,monospace;">${e.move}</span>
${renderExplorerBar(e.white, e.draw, e.black)}
<span style="font-size:9px;color:#64748b;">${e.games}</span>
</div>`
).join('');
linesContainer.innerHTML = `<div style="border-bottom:1px solid rgba(255,255,255,0.05);padding-bottom:4px;margin-bottom:4px;">
<div style="font-size:9px;color:#64748b;margin-bottom:2px;">📖 كتاب الافتتاحات</div>${explorerHtml}</div>
<div style="color:#64748b;font-size:11px;text-align:center;">جاري تحليل المحرك...</div>`;
}
try { try {
const data = await net.post('analysis.php', { fen, depth: 18, lines: 3 }); const data = await net.post('analysis.php', { fen, depth: 18, lines: 3 });
if (data.lines && data.lines.length > 0) { if (data.lines && data.lines.length > 0) {
renderAnalysisLines(el, data.lines, fen); renderAnalysisLines(el, data.lines, fen, explorerData);
updateEvalBar(el, data.lines[0].evaluation); updateEvalBar(el, data.lines[0].evaluation);
} }
} catch (e) { } catch (e) {
if (!explorerData) {
linesContainer.innerHTML = '<div style="color:#ef4444;font-size:12px;text-align:center;">فشل التحليل</div>'; linesContainer.innerHTML = '<div style="color:#ef4444;font-size:12px;text-align:center;">فشل التحليل</div>';
} }
}
} }
function renderAnalysisLines(el, lines, fen) { function renderAnalysisLines(el, lines, fen, explorerData) {
const container = el.querySelector('#analysis-lines'); const container = el.querySelector('#analysis-lines');
container.innerHTML = lines.map((line, i) => {
let explorerHtml = '';
if (explorerData) {
explorerHtml = `<div style="border-bottom:1px solid rgba(255,255,255,0.05);padding-bottom:4px;margin-bottom:4px;">
<div style="font-size:9px;color:#64748b;margin-bottom:2px;">📖 كتاب الافتتاحات</div>
${explorerData.slice(0, 3).map(e =>
`<div style="display:flex;align-items:center;gap:6px;padding:1px 0;">
<span style="font-size:11px;font-weight:600;color:#f8fafc;min-width:32px;font-family:Inter,monospace;">${e.move}</span>
${renderExplorerBar(e.white, e.draw, e.black)}
<span style="font-size:9px;color:#64748b;">${e.games}</span>
</div>`
).join('')}
</div>`;
}
container.innerHTML = explorerHtml + lines.map((line, i) => {
const evalStr = formatEval(line.evaluation); const evalStr = formatEval(line.evaluation);
const isPositive = line.evaluation >= 0; const isPositive = line.evaluation >= 0;
const color = isPositive ? '#34D399' : '#F87171'; const color = isPositive ? '#34D399' : '#F87171';
......
// Puzzle theme definitions with Arabic names and icons
export const THEMES = [
{ key: 'mate', name: 'كش مات', nameEn: 'Checkmate', icon: '♚' },
{ key: 'short', name: 'قصيرة', nameEn: 'Short', icon: '⚡' },
{ key: 'long', name: 'طويلة', nameEn: 'Long', icon: '📏' },
{ key: 'sacrifice', name: 'تضحية', nameEn: 'Sacrifice', icon: '💥' },
{ key: 'fork', name: 'شوكة', nameEn: 'Fork', icon: '🔱' },
{ key: 'pin', name: 'تثبيت', nameEn: 'Pin', icon: '📌' },
{ key: 'skewer', name: 'سيخ', nameEn: 'Skewer', icon: '🗡️' },
{ key: 'back-rank', name: 'الصف الأخير', nameEn: 'Back Rank', icon: '🏰' },
{ key: 'discovery', name: 'كشف', nameEn: 'Discovered Attack', icon: '👁️' },
{ key: 'double-check', name: 'كشف مزدوج', nameEn: 'Double Check', icon: '⚔️' },
{ key: 'attack', name: 'هجوم', nameEn: 'Attack', icon: '⚔️' },
{ key: 'defense', name: 'دفاع', nameEn: 'Defense', icon: '🛡️' },
{ key: 'endgame', name: 'نهايات', nameEn: 'Endgame', icon: '🏁' },
{ key: 'promotion', name: 'ترقية', nameEn: 'Promotion', icon: '👑' },
{ key: 'trapped', name: 'محاصرة', nameEn: 'Trapped Piece', icon: '🪤' },
{ key: 'zugzwang', name: 'تسوغزوانغ', nameEn: 'Zugzwang', icon: '⏳' },
];
export function getThemeName(key, lang = 'ar') {
const theme = THEMES.find(t => t.key === key);
if (!theme) return key;
return lang === 'ar' ? theme.name : theme.nameEn;
}
export function getThemeIcon(key) {
const theme = THEMES.find(t => t.key === key);
return theme?.icon || '♟';
}
export function parseThemes(themesStr) {
if (!themesStr) return [];
return themesStr.split(',').map(t => t.trim()).filter(Boolean);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment