Add interactive variation playback

This commit is contained in:
Michael committed 2026-07-20 16:59:13 +02:00
1 parent c944e8fda5
commit 958436d91b
2 files changed
+51 -5

No files matched your search

+47 -3
View File
@@ -1,7 +1,7 @@
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, RotateCcw, Settings, Sparkles, X } from 'lucide-react';
import { Brain, ChevronRight, CircleHelp, FlipVertical2, Lightbulb, LoaderCircle, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Sparkles, X } from 'lucide-react';
import './styles.css';
const PIECES = { p: '♟', n: '♞', b: '♝', r: '♜', q: '♛', k: '♚', P: '♟', N: '♞', B: '♝', R: '♜', Q: '♛', K: '♚' };
@@ -103,6 +103,9 @@ function App() {
const [explaining, setExplaining] = useState(false);
const [showHint, setShowHint] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [previewLine, setPreviewLine] = useState(null);
const [previewStep, setPreviewStep] = useState(0);
const [previewPlaying, setPreviewPlaying] = useState(false);
const [settings, setSettings] = useState(() => {
try { return { ...defaultSettings, ...JSON.parse(localStorage.getItem('atelier64-settings')) }; } catch { return defaultSettings; }
});
@@ -110,6 +113,17 @@ function App() {
const targets = selected ? game.moves({ square: selected, verbose: true }).map((m) => m.to) : [];
const bestMove = showHint ? analysis[0]?.pv?.[0] : null;
const lastMove = moves.at(-1)?.uci;
const previewVariation = previewLine === null ? null : analysis[previewLine];
const previewGame = useMemo(() => {
const replay = new Chess(fen);
for (const move of previewVariation?.moves?.slice(0, previewStep) || []) {
try { replay.move({ from: move.uci.slice(0, 2), to: move.uci.slice(2, 4), promotion: move.uci[4] || 'q' }); } catch { break; }
}
return replay;
}, [fen, previewVariation, previewStep]);
const displayedGame = previewVariation ? previewGame : game;
const previewLastMove = previewVariation?.moves?.[previewStep - 1]?.uci;
const previewNextMove = previewVariation?.moves?.[previewStep]?.uci;
useEffect(() => {
const controller = new AbortController();
@@ -126,6 +140,28 @@ function App() {
return () => { clearTimeout(timer); controller.abort(); };
}, [fen, settings.depth]);
useEffect(() => {
if (!previewPlaying || !previewVariation) return;
if (previewStep >= previewVariation.moves.length) {
setPreviewPlaying(false);
return;
}
const timer = setTimeout(() => setPreviewStep((step) => step + 1), 900);
return () => clearTimeout(timer);
}, [previewPlaying, previewStep, previewVariation]);
useEffect(() => {
setPreviewLine(null); setPreviewStep(0); setPreviewPlaying(false);
}, [fen]);
function openVariation(index) {
setPreviewLine(index); setPreviewStep(1); setPreviewPlaying(true); setSelected(null);
}
function closeVariation() {
setPreviewLine(null); setPreviewStep(0); setPreviewPlaying(false);
}
function attemptMove(from, to) {
const next = new Chess(fen);
try {
@@ -183,7 +219,8 @@ function App() {
<div className="game-meta"><div><span className="eyebrow">PARTIE LIBRE · DEUX CÔTÉS</span><h1>{status}</h1></div><div className="turn-chip"><span className={game.turn() === 'w' ? 'mini-white' : 'mini-black'}/>{game.turn() === 'w' ? 'BLANCS' : 'NOIRS'}</div></div>
<div className="board-area">
<div className="eval-bar" aria-label="Évaluation"><span className="eval-number">{scoreLabel(rawScore)}</span><div className="eval-track"><div className="eval-white" style={{ height: `${whiteShare}%` }}/></div></div>
<ChessBoard game={game} orientation={orientation} selected={selected} targets={targets} bestMove={bestMove} lastMove={lastMove} onSquare={onSquare}/>
{previewVariation && <div className="preview-ribbon"><span>VARIANTE {String(previewLine + 1).padStart(2, '0')}</span><b>{previewStep}/{previewVariation.moves.length}</b><button onClick={closeVariation} aria-label="Fermer aperçu"><X size={15}/></button></div>}
<ChessBoard game={displayedGame} orientation={orientation} selected={previewVariation ? null : selected} targets={previewVariation ? [] : targets} bestMove={previewVariation ? previewNextMove : bestMove} lastMove={previewVariation ? previewLastMove : lastMove} onSquare={previewVariation ? () => {} : onSquare}/>
</div>
<div className="board-actions">
<button onClick={undo} disabled={!history.length}><RotateCcw size={17}/> Annuler</button>
@@ -201,7 +238,14 @@ function App() {
<section className="variations">
<div className="section-label"><span>VARIANTES PRINCIPALES</span><span>ÉVAL.</span></div>
{analysis.slice(0, 3).map((line, index) => <div className="variation" key={index}><span className="variation-number">{String(index + 1).padStart(2, '0')}</span><div>{line.moves?.slice(0, 6).map((m, i) => <span key={i} className={i === 0 ? 'first-san' : ''}>{m.san} </span>)}</div><b>{scoreLabel(line.score)}</b></div>)}
{analysis.slice(0, 3).map((line, index) => <button type="button" className={`variation ${previewLine === index ? 'active' : ''}`} onClick={() => openVariation(index)} key={index} aria-label={`Voir variante ${index + 1} sur l'échiquier`}><span className="variation-number">{String(index + 1).padStart(2, '0')}</span><span className="variation-moves">{line.moves?.slice(0, 6).map((m, i) => <span key={i} className={i === 0 ? 'first-san' : ''}>{m.san} </span>)}</span><b>{scoreLabel(line.score)}</b></button>)}
{previewVariation && <div className="variation-player">
<button onClick={() => { setPreviewPlaying(false); setPreviewStep(0); }} aria-label="Début variante"><SkipBack size={16}/></button>
<button className="player-main" onClick={() => { if (previewStep >= previewVariation.moves.length) setPreviewStep(0); setPreviewPlaying(!previewPlaying); }} aria-label={previewPlaying ? 'Pause' : 'Lecture'}>{previewPlaying ? <Pause size={17}/> : <Play size={17}/>}</button>
<button onClick={() => { setPreviewPlaying(false); setPreviewStep(Math.min(previewVariation.moves.length, previewStep + 1)); }} aria-label="Coup suivant"><SkipForward size={16}/></button>
<span>{previewStep ? previewVariation.moves[previewStep - 1]?.san : 'Position initiale'}</span>
<button className="close-player" onClick={closeVariation}>Quitter</button>
</div>}
</section>
<section className="coach-note">
+4 -2
View File
@@ -20,7 +20,8 @@ main { width:min(1450px,94vw); margin:0 auto; padding:34px 0 45px; display:grid;
.eyebrow { font:10px 'DM Mono',monospace;letter-spacing:.19em;color:var(--copper); }
h1,h2 { font-family:'Playfair Display',serif;margin:4px 0 0;font-weight:600; } h1 { font-size:clamp(24px,2.1vw,34px); } h2 { font-size:27px; }
.turn-chip { font:10px 'DM Mono',monospace;letter-spacing:.12em;display:flex;align-items:center;gap:8px;padding-bottom:5px; }.mini-white,.mini-black { width:12px;height:12px;border-radius:50%;display:block;box-shadow:0 0 0 1px var(--ink); }.mini-white{background:var(--cream)}.mini-black{background:var(--ink)}
.board-area { display:flex; gap:16px; align-items:stretch; }
.board-area { display:flex; gap:16px; align-items:stretch;position:relative; }
.preview-ribbon{position:absolute;z-index:8;top:10px;left:44px;background:rgba(17,20,17,.92);color:var(--cream);padding:8px 9px 8px 12px;display:flex;align-items:center;gap:11px;box-shadow:0 6px 18px rgba(0,0,0,.2);font:9px 'DM Mono',monospace;letter-spacing:.13em}.preview-ribbon b{color:#e99567}.preview-ribbon button{border:0;border-left:1px solid #4b4e45;background:transparent;color:var(--cream);padding:0 0 0 8px;display:grid;place-items:center;cursor:pointer}
.eval-bar { width:18px; position:relative; display:flex;flex-direction:column;gap:8px; }.eval-number { font:9px 'DM Mono',monospace; writing-mode:vertical-rl;transform:rotate(180deg);height:34px; }.eval-track { background:var(--ink);flex:1;position:relative;overflow:hidden; }.eval-white { position:absolute;bottom:0;width:100%;background:var(--cream);transition:height .55s cubic-bezier(.2,.8,.2,1); }
.board-shell { width:min(100%,calc(100vh - 220px)); aspect-ratio:1; box-shadow:0 25px 50px rgba(55,43,27,.18),0 2px 0 rgba(255,255,255,.4) inset; border:7px solid #292a24; }
.board { display:grid;grid-template-columns:repeat(8,minmax(0,1fr));grid-template-rows:repeat(8,minmax(0,1fr));position:relative;width:100%;height:100%;border:1px solid #10110f; }
@@ -34,7 +35,8 @@ h1,h2 { font-family:'Playfair Display',serif;margin:4px 0 0;font-weight:600; } h
.coach-column { border-left:1px solid var(--line);padding-left:clamp(28px,4vw,60px);min-width:0; }
.coach-title { display:flex;align-items:center;gap:14px;margin:4px 0 23px; }.coach-orb{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;background:var(--ink);color:var(--paper);box-shadow:0 0 0 7px rgba(17,20,17,.06)}.analysis-spinner{margin-left:auto;color:var(--copper)}
.best-card { background:var(--ink);color:var(--cream);padding:22px 25px 24px;position:relative;overflow:hidden; }.best-card:after{content:'♞';font:130px 'Times New Roman';position:absolute;right:-12px;bottom:-48px;color:rgba(255,255,255,.035);}.card-index{font:10px 'DM Mono';color:var(--copper);margin-right:13px}.card-label{font:10px 'DM Mono';letter-spacing:.18em;color:#a9aa9f}.best-move{display:flex;align-items:baseline;gap:13px;margin-top:19px}.best-move strong{font:italic 46px 'Playfair Display',serif}.best-move span{font:12px 'DM Mono';padding:4px 7px;background:var(--copper);}.best-card p{font-size:12px;color:#aaa99f;margin:2px 0 16px}.hint-toggle{position:relative;z-index:2;border:1px solid #45473f;background:transparent;color:#bbbcb1;padding:9px 12px;display:flex;align-items:center;gap:7px;cursor:pointer;font-size:11px}.hint-toggle.active{color:#f0bb68;border-color:#8b7148}.empty-analysis{padding:34px 0 23px;color:#96978d;font-family:'Playfair Display',serif;font-style:italic}
.variations { margin-top:26px; }.section-label{display:flex;justify-content:space-between;border-bottom:1px solid var(--ink);padding-bottom:8px;font:9px 'DM Mono';letter-spacing:.15em;color:#69665e}.variation{display:grid;grid-template-columns:30px 1fr 42px;gap:10px;align-items:start;border-bottom:1px solid var(--line);padding:14px 2px;font-size:13px;line-height:1.7}.variation-number{font:10px 'DM Mono';color:var(--copper);padding-top:4px}.variation b:last-child{text-align:right;font:500 11px 'DM Mono';padding-top:4px}.first-san{font-weight:800;color:var(--copper)}
.variations { margin-top:26px; }.section-label{display:flex;justify-content:space-between;border-bottom:1px solid var(--ink);padding-bottom:8px;font:9px 'DM Mono';letter-spacing:.15em;color:#69665e}.variation{width:100%;font-family:inherit;text-align:left;background:transparent;cursor:pointer;display:grid;grid-template-columns:30px 1fr 42px;gap:10px;align-items:start;border:0;border-bottom:1px solid var(--line);padding:14px 2px;font-size:13px;line-height:1.7;transition:background .18s,padding .18s}.variation:hover{background:rgba(199,109,69,.08);padding-left:8px}.variation.active{background:var(--ink);color:var(--cream);padding-left:10px;padding-right:8px}.variation-number{font:10px 'DM Mono';color:var(--copper);padding-top:4px}.variation-moves{display:block}.variation b:last-child{text-align:right;font:500 11px 'DM Mono';padding-top:4px}.first-san{font-weight:800;color:var(--copper)}
.variation-player{display:flex;align-items:center;gap:5px;padding:11px 8px;background:#ded5c3;border-bottom:1px solid var(--ink)}.variation-player button{width:30px;height:30px;border:1px solid rgba(17,20,17,.25);background:transparent;display:grid;place-items:center;cursor:pointer}.variation-player .player-main{background:var(--copper);border-color:var(--copper);color:white}.variation-player>span{font:11px 'DM Mono';margin-left:7px;color:#5d594f}.variation-player .close-player{width:auto;margin-left:auto;padding:0 5px;border:0;border-bottom:1px solid var(--ink);font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}
.coach-note { background:#ded5c3;margin-top:26px;padding:20px 22px;border-left:3px solid var(--copper); }.note-heading{display:flex;align-items:center;gap:8px;font:10px 'DM Mono';letter-spacing:.15em}.coach-note p{font-family:'Playfair Display',serif;font-size:15px;line-height:1.55;margin:13px 0;color:#3b3a34}.explain-button{border:0;border-bottom:1px solid #383933;background:transparent;padding:5px 0;display:flex;align-items:center;gap:7px;text-transform:uppercase;font-size:10px;font-weight:800;letter-spacing:.09em;cursor:pointer}.explain-button svg:last-child{margin-left:4px}.explain-button:disabled{opacity:.5}
.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; }