mirror of
https://github.com/R0m1k3/chesspreview.git
synced 2026-10-11 17:28:39 +02:00
Add custom position analysis mode
This commit is contained in:
1 parent
9888a606c0
commit
9680e11bb0
4 files changed
+114
-2
No files matched your search
+75
-1
@@ -1,14 +1,19 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Chess } from 'chess.js';
|
||||
import { Brain, ChevronRight, CircleHelp, FlipVertical2, Lightbulb, LoaderCircle, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Sparkles, X } from 'lucide-react';
|
||||
import { Brain, ChevronRight, CircleHelp, FlipVertical2, LayoutGrid, Lightbulb, LoaderCircle, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Sparkles, Trash2, X } from 'lucide-react';
|
||||
import './styles.css';
|
||||
import { buildFen, positionFromGame } from './position.js';
|
||||
|
||||
const PIECES = { p: '♟', n: '♞', b: '♝', r: '♜', q: '♛', k: '♚', P: '♟', N: '♞', B: '♝', R: '♜', Q: '♛', K: '♚' };
|
||||
const FILES = ['a','b','c','d','e','f','g','h'];
|
||||
const RANKS = ['8','7','6','5','4','3','2','1'];
|
||||
const START_FEN = new Chess().fen();
|
||||
const defaultSettings = { ollamaUrl: 'http://host.docker.internal:11434', model: 'qwen3:8b', depth: 16 };
|
||||
const POSITION_PRESETS = {
|
||||
middlegame: 'r2q1rk1/pp2bppp/2npbn2/2p1p3/4P3/2NP1N2/PPQ1BPPP/R1B2RK1 w - - 0 10',
|
||||
endgame: '8/5pk1/3p2p1/1p1P4/1P2P3/5P2/5KPP/8 w - - 0 32',
|
||||
};
|
||||
|
||||
function scoreLabel(score) {
|
||||
if (!score) return '—';
|
||||
@@ -90,6 +95,68 @@ function SettingsPanel({ value, onSave, onClose }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PositionEditor({ fen, onApply, onClose }) {
|
||||
const [position, setPosition] = useState(() => positionFromGame(new Chess(fen)));
|
||||
const [turn, setTurn] = useState(() => new Chess(fen).turn());
|
||||
const [tool, setTool] = useState('wq');
|
||||
const [editorError, setEditorError] = useState('');
|
||||
const palette = ['wk', 'wq', 'wr', 'wb', 'wn', 'wp', 'bk', 'bq', 'br', 'bb', 'bn', 'bp'];
|
||||
|
||||
function loadPreset(nextFen) {
|
||||
const next = new Chess(nextFen);
|
||||
setPosition(positionFromGame(next)); setTurn(next.turn()); setEditorError('');
|
||||
}
|
||||
|
||||
function place(square) {
|
||||
setPosition((current) => {
|
||||
const next = { ...current };
|
||||
if (tool === 'erase') delete next[square];
|
||||
else next[square] = { color: tool[0], type: tool[1] };
|
||||
return next;
|
||||
});
|
||||
setEditorError('');
|
||||
}
|
||||
|
||||
function applyPosition() {
|
||||
const pieces = Object.values(position);
|
||||
if (pieces.filter((p) => p.type === 'k' && p.color === 'w').length !== 1 || pieces.filter((p) => p.type === 'k' && p.color === 'b').length !== 1) {
|
||||
setEditorError('Placez exactement un roi blanc et un roi noir.'); return;
|
||||
}
|
||||
const candidate = buildFen(position, turn);
|
||||
try { new Chess(candidate); onApply(candidate); }
|
||||
catch { setEditorError('Position illégale. Vérifiez rois, pions et mises en échec.'); }
|
||||
}
|
||||
|
||||
return <div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<section className="position-editor" role="dialog" aria-modal="true" aria-label="Composer une position">
|
||||
<div className="panel-heading"><div><span className="eyebrow">LABORATOIRE</span><h2>Composer une position</h2></div><button className="icon-button" onClick={onClose}><X size={20}/></button></div>
|
||||
<div className="editor-layout">
|
||||
<div className="editor-board" aria-label="Éditeur de position">
|
||||
{RANKS.flatMap((rank) => FILES.map((file) => {
|
||||
const square = `${file}${rank}`;
|
||||
const piece = position[square];
|
||||
const dark = (FILES.indexOf(file) + Number(rank)) % 2 === 1;
|
||||
const code = piece ? (piece.color === 'w' ? piece.type.toUpperCase() : piece.type) : null;
|
||||
return <button type="button" key={square} className={dark ? 'dark' : 'light'} onClick={() => place(square)} aria-label={`Placer sur ${square}`}>
|
||||
{piece && <span className={`piece ${piece.color === 'w' ? 'white-piece' : 'black-piece'}`}>{PIECES[code]}</span>}
|
||||
</button>;
|
||||
}))}
|
||||
</div>
|
||||
<div className="editor-tools">
|
||||
<div><span className="tool-label">PIÈCES</span><div className="piece-palette">{palette.map((item) => {
|
||||
const code = item[0] === 'w' ? item[1].toUpperCase() : item[1];
|
||||
return <button key={item} className={`${tool === item ? 'active' : ''} ${item[0] === 'w' ? 'palette-white' : 'palette-black'}`} onClick={() => setTool(item)} aria-label={`Sélectionner ${item}`}><span>{PIECES[code]}</span></button>;
|
||||
})}</div></div>
|
||||
<button className={`erase-tool ${tool === 'erase' ? 'active' : ''}`} onClick={() => setTool('erase')}><Trash2 size={16}/> Gomme</button>
|
||||
<div><span className="tool-label">TRAIT</span><div className="turn-choice"><button className={turn === 'w' ? 'active' : ''} onClick={() => setTurn('w')}><i className="mini-white"/> Blancs</button><button className={turn === 'b' ? 'active' : ''} onClick={() => setTurn('b')}><i className="mini-black"/> Noirs</button></div></div>
|
||||
<div><span className="tool-label">EXEMPLES</span><div className="preset-list"><button onClick={() => loadPreset(POSITION_PRESETS.middlegame)}>Milieu de partie</button><button onClick={() => loadPreset(POSITION_PRESETS.endgame)}>Finale</button><button onClick={() => { setPosition({}); setEditorError(''); }}>Vider plateau</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-footer"><p>{editorError || 'Choisissez côté au trait. Stockfish analysera position dès validation.'}</p><button className="primary" onClick={applyPosition}>Analyser cette position</button></div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [fen, setFen] = useState(START_FEN);
|
||||
const [moves, setMoves] = useState([]);
|
||||
@@ -103,6 +170,7 @@ function App() {
|
||||
const [explaining, setExplaining] = useState(false);
|
||||
const [showHint, setShowHint] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showPositionEditor, setShowPositionEditor] = useState(false);
|
||||
const [previewLine, setPreviewLine] = useState(null);
|
||||
const [previewStep, setPreviewStep] = useState(0);
|
||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||
@@ -197,6 +265,10 @@ function App() {
|
||||
|
||||
function reset() { setFen(START_FEN); setMoves([]); setHistory([]); setSelected(null); setAnalysis([]); setExplanation(''); }
|
||||
|
||||
function loadComposedPosition(nextFen) {
|
||||
setFen(nextFen); setMoves([]); setHistory([]); setSelected(null); setAnalysis([]); setExplanation(''); setShowPositionEditor(false); setShowHint(true);
|
||||
}
|
||||
|
||||
async function explain() {
|
||||
if (!analysis.length) return;
|
||||
setExplaining(true); setError('');
|
||||
@@ -232,6 +304,7 @@ function App() {
|
||||
<div className="board-actions">
|
||||
<button onClick={undo} disabled={!history.length}><RotateCcw size={17}/> Annuler</button>
|
||||
<button onClick={() => setOrientation(orientation === 'white' ? 'black' : 'white')}><FlipVertical2 size={17}/> Retourner</button>
|
||||
<button onClick={() => setShowPositionEditor(true)}><LayoutGrid size={17}/> Composer</button>
|
||||
<button onClick={reset}>Nouvelle partie</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -266,6 +339,7 @@ function App() {
|
||||
</main>
|
||||
<footer><span>ATELIER 64</span><span>Les coups viennent de Stockfish · Les explications viennent de {settings.model}</span></footer>
|
||||
{showSettings && <SettingsPanel value={settings} onClose={() => setShowSettings(false)} onSave={(next) => { setSettings(next); localStorage.setItem('atelier64-settings', JSON.stringify(next)); setShowSettings(false); }}/>}
|
||||
{showPositionEditor && <PositionEditor fen={fen} onClose={() => setShowPositionEditor(false)} onApply={loadComposedPosition}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||
const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1'];
|
||||
|
||||
export function positionFromGame(game) {
|
||||
const position = {};
|
||||
for (const rank of RANKS) {
|
||||
for (const file of FILES) {
|
||||
const piece = game.get(`${file}${rank}`);
|
||||
if (piece) position[`${file}${rank}`] = { type: piece.type, color: piece.color };
|
||||
}
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
export function buildFen(position, turn = 'w') {
|
||||
const rows = RANKS.map((rank) => {
|
||||
let row = '';
|
||||
let empty = 0;
|
||||
for (const file of FILES) {
|
||||
const piece = position[`${file}${rank}`];
|
||||
if (!piece) { empty += 1; continue; }
|
||||
if (empty) { row += empty; empty = 0; }
|
||||
row += piece.color === 'w' ? piece.type.toUpperCase() : piece.type;
|
||||
}
|
||||
return row + (empty || '');
|
||||
});
|
||||
return `${rows.join('/')} ${turn} - - 0 1`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildFen } from './position.js';
|
||||
|
||||
test('construit FEN depuis position composée', () => {
|
||||
const position = { e1: { type: 'k', color: 'w' }, e8: { type: 'k', color: 'b' }, d4: { type: 'q', color: 'w' } };
|
||||
assert.equal(buildFen(position, 'b'), '4k3/8/8/8/3Q4/8/8/4K3 b - - 0 1');
|
||||
});
|
||||
+3
-1
@@ -41,6 +41,8 @@ h1,h2 { font-family:'Playfair Display',serif;margin:4px 0 0;font-weight:600; } h
|
||||
.moves-panel{margin-top:27px}.move-list{max-height:155px;overflow:auto;padding-top:6px}.move-row{display:grid;grid-template-columns:35px 1fr 1fr;padding:6px 2px;font-size:12px}.move-row span{font:10px 'DM Mono';color:#8d887d}.move-row b{font-weight:600}.moves-empty{font:italic 14px 'Playfair Display';color:#8a857a;display:block;padding:16px 0}.error-banner{font-size:11px;padding:11px 13px;margin-top:15px;background:#b94c3d;color:white}
|
||||
footer { border-top:1px solid var(--line);padding:18px 4vw;display:flex;justify-content:space-between;color:#8d877b;font:9px 'DM Mono';letter-spacing:.12em; }
|
||||
.modal-backdrop{position:fixed;inset:0;z-index:20;background:rgba(13,15,12,.68);backdrop-filter:blur(8px);display:grid;place-items:center;padding:20px}.settings-panel{width:min(540px,100%);background:var(--cream);box-shadow:0 30px 90px rgba(0,0,0,.35);padding:29px}.panel-heading{display:flex;justify-content:space-between;align-items:start;margin-bottom:24px}.icon-button{border:0;background:transparent;cursor:pointer}.settings-panel label{display:block;font-size:12px;font-weight:700;margin-top:18px}.settings-panel label>span{float:right;font:11px 'DM Mono';color:var(--copper)}.settings-panel input:not([type=range]){display:block;width:100%;margin-top:7px;background:transparent;border:0;border-bottom:1px solid #8e897f;padding:10px 2px;font:13px 'DM Mono';outline:none}.settings-panel input:focus{border-color:var(--copper)}.settings-panel input[type=range]{display:block;width:100%;accent-color:var(--copper);margin-top:12px}.field-note{font-size:10px;color:#777268;margin:7px 0}.recommendation{margin-top:19px;padding:13px;background:#e7dfcf;display:flex;gap:10px;align-items:flex-start;font-size:11px;line-height:1.5}.recommendation svg{flex:0 0 auto;color:var(--copper)}.settings-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:27px}.settings-actions button{padding:11px 16px;border:1px solid var(--ink);font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;display:flex;gap:7px;align-items:center}.settings-actions .primary{background:var(--ink);color:var(--cream)}.settings-actions .secondary{background:transparent}.connection-status{font-size:11px;text-align:right}.connection-status.success{color:#52704e}.connection-status.error{color:#b24b3c}.spin{animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.position-editor{width:min(900px,96vw);max-height:94vh;overflow:auto;background:var(--cream);box-shadow:0 30px 90px rgba(0,0,0,.38);padding:28px}.position-editor .panel-heading{margin-bottom:20px}.editor-layout{display:grid;grid-template-columns:minmax(330px,1fr) 280px;gap:28px}.editor-board{width:100%;aspect-ratio:1;display:grid;grid-template-columns:repeat(8,minmax(0,1fr));grid-template-rows:repeat(8,minmax(0,1fr));border:6px solid var(--ink);box-shadow:0 15px 30px rgba(25,22,17,.17)}.editor-board>button{border:0;padding:0;min-width:0;min-height:0;display:grid;place-items:center;cursor:pointer}.editor-board>button.light{background:#d8d0bc}.editor-board>button.dark{background:#67725e}.editor-board .piece{font-size:clamp(27px,5vw,54px);pointer-events:none}.editor-tools{display:flex;flex-direction:column;gap:19px}.tool-label{display:block;font:9px 'DM Mono';letter-spacing:.16em;color:#777267;margin-bottom:8px}.piece-palette{display:grid;grid-template-columns:repeat(6,1fr);border:1px solid var(--line)}.piece-palette button{height:45px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);font:29px 'Times New Roman';cursor:pointer;display:grid;place-items:center}.piece-palette button:nth-child(6n){border-right:0}.piece-palette button.active{box-shadow:inset 0 0 0 3px var(--copper);position:relative;z-index:1}.palette-white{background:#68735f;color:#f8f0dd;-webkit-text-stroke:1px #20231d}.palette-black{background:#d8d0bc;color:#171914}.erase-tool{align-self:flex-start;border:1px solid var(--line);background:transparent;padding:8px 11px;display:flex;align-items:center;gap:7px;cursor:pointer;font-size:10px;text-transform:uppercase;font-weight:800}.erase-tool.active{background:var(--copper);color:white;border-color:var(--copper)}.turn-choice{display:grid;grid-template-columns:1fr 1fr;gap:7px}.turn-choice button{border:1px solid var(--line);background:transparent;padding:10px;display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer;font-size:11px}.turn-choice button.active{border-color:var(--ink);background:#e6dece;font-weight:800}.turn-choice i{flex:0 0 auto}.preset-list{display:flex;flex-direction:column}.preset-list button{border:0;border-bottom:1px solid var(--line);background:transparent;text-align:left;padding:9px 1px;cursor:pointer;font:12px 'Playfair Display',serif}.preset-list button:hover{color:var(--copper);padding-left:5px}.editor-footer{display:flex;align-items:center;justify-content:flex-end;gap:20px;margin-top:22px;border-top:1px solid var(--line);padding-top:17px}.editor-footer p{margin:0 auto 0 0;font-size:11px;color:#716c62}.editor-footer .primary{border:1px solid var(--ink);background:var(--ink);color:var(--cream);padding:12px 16px;text-transform:uppercase;font-size:10px;font-weight:800;letter-spacing:.07em;cursor:pointer}
|
||||
@media(max-width:980px){header{padding:0 22px}.header-status{display:none}main{grid-template-columns:1fr;width:min(720px,94vw)}.board-shell{width:100%}.coach-column{border-left:0;border-top:1px solid var(--line);padding:32px 0 0}.game-meta{padding-left:32px}}
|
||||
@media(max-width:560px){header{height:64px}.brand small{display:none}.settings-button{font-size:0;padding:10px}.settings-button svg{width:19px;height:19px}.game-meta{padding-left:27px}.turn-chip{display:none}main{padding-top:22px}.board-area{gap:9px}.eval-bar{width:16px}.board-shell{border-width:4px}.piece{font-size:11vw}.board-actions{padding-left:25px;gap:14px}.board-actions button{font-size:0}.board-actions button svg{width:20px;height:20px}.board-actions button:last-child{font-size:10px}footer{gap:20px;line-height:1.5}.settings-panel{padding:22px}}
|
||||
@media(max-width:720px){.position-editor{padding:19px}.editor-layout{grid-template-columns:1fr}.editor-board{max-width:500px;margin:auto}.editor-tools{display:grid;grid-template-columns:1fr 1fr}.editor-tools>div:first-child{grid-column:1/-1}.editor-footer{align-items:flex-end}}
|
||||
@media(max-width:560px){header{height:64px}.brand small{display:none}.settings-button{font-size:0;padding:10px}.settings-button svg{width:19px;height:19px}.game-meta{padding-left:27px}.turn-chip{display:none}main{padding-top:22px}.board-area{gap:9px}.eval-bar{width:16px}.board-shell{border-width:4px}.piece{font-size:11vw}.board-actions{padding-left:25px;gap:14px}.board-actions button{font-size:0}.board-actions button svg{width:20px;height:20px}.board-actions button:last-child{font-size:10px}footer{gap:20px;line-height:1.5}.settings-panel{padding:22px}.editor-tools{grid-template-columns:1fr}.editor-tools>div:first-child{grid-column:auto}.editor-footer{display:block}.editor-footer .primary{width:100%;margin-top:10px}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
|
||||
Reference in new issue
Block a user