diff --git a/src/main.jsx b/src/main.jsx index 8f98b78..b87adb1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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 }) { ; } +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
e.target === e.currentTarget && onClose()}> +
+
LABORATOIRE

Composer une 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 ; + }))} +
+
+
PIÈCES
{palette.map((item) => { + const code = item[0] === 'w' ? item[1].toUpperCase() : item[1]; + return ; + })}
+ +
TRAIT
+
EXEMPLES
+
+
+

{editorError || 'Choisissez côté au trait. Stockfish analysera position dès validation.'}

+
+
; +} + 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() {
+
@@ -266,6 +339,7 @@ function App() {
ATELIER 64Les coups viennent de Stockfish · Les explications viennent de {settings.model}
{showSettings && setShowSettings(false)} onSave={(next) => { setSettings(next); localStorage.setItem('atelier64-settings', JSON.stringify(next)); setShowSettings(false); }}/>} + {showPositionEditor && setShowPositionEditor(false)} onApply={loadComposedPosition}/>} ; } diff --git a/src/position.js b/src/position.js new file mode 100644 index 0000000..b9d4a5a --- /dev/null +++ b/src/position.js @@ -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`; +} diff --git a/src/position.test.js b/src/position.test.js new file mode 100644 index 0000000..97ba1cc --- /dev/null +++ b/src/position.test.js @@ -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'); +}); diff --git a/src/styles.css b/src/styles.css index e05d807..1093085 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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}}