Add custom position analysis mode

This commit is contained in:
Michael committed 2026-07-20 17:50:10 +02:00
1 parent 9888a606c0
commit 9680e11bb0
4 files changed
+114 -2

No files matched your search

+75 -1
View File
@@ -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>;
}
+28
View File
@@ -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`;
}
+8
View File
@@ -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
View File
@@ -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}}