explorer / frontend /src /hooks /useExplorer.js
noobpwnftw's picture
Deploy ChessTB Explorer on Hugging Face Spaces
e16348e
Raw History Blame
22.5 kB
import { useEffect, useMemo, useRef, useState } from 'react';
import { Board, VARIANTS } from '../engine/board.js';
import { Tablebase } from '../engine/tablebase.js';
import { Theme } from '../engine/theme.js';
import * as Storage from '../engine/storage.js';
import { debounce } from '../engine/utils.js';
import {
writeHash, readHashFen, readHashVariant, readLastGame, saveGameState, normalizeFen,
} from '../engine/gameState.js';
import { buildPgnText, parsePgn } from '../engine/pgnFormat.js';
const DEFAULT_FEN = '4k3/8/8/8/8/8/8/4K2R w - - 0 1';
const METRIC_CONFIG = {
dtz: { dataKey: 'moves_dtz', label: 'DTZ' },
dtc: { dataKey: 'moves_dtc', label: 'DTC' },
dtm: { dataKey: 'moves_dtm', label: 'DTM' },
dtm50: { dataKey: 'moves_dtm50', label: 'DTM50' },
};
const AUTO_PLAY_METRICS = Object.keys(METRIC_CONFIG);
const AUTO_PLAY_DELAY_MIN_MS = 0;
const AUTO_PLAY_DELAY_MAX_MS = 2500;
const AUTO_PLAY_DELAY_STEP_MS = 50;
const AUTO_PLAY_DELAY_DEFAULT_MS = 1250;
const LS_AUTOPLAY_DELAY_MS = 'chesstb_autoplay_delay_ms';
function clampAutoPlayDelayMs(ms) {
if (!Number.isFinite(ms)) return AUTO_PLAY_DELAY_DEFAULT_MS;
const snapped = Math.round(ms / AUTO_PLAY_DELAY_STEP_MS) * AUTO_PLAY_DELAY_STEP_MS;
return Math.min(AUTO_PLAY_DELAY_MAX_MS, Math.max(AUTO_PLAY_DELAY_MIN_MS, snapped));
}
function readAutoPlayDelayMs() {
const raw = Storage.read(LS_AUTOPLAY_DELAY_MS);
const ms = raw === null ? NaN : parseInt(raw, 10);
return Number.isFinite(ms) ? clampAutoPlayDelayMs(ms) : AUTO_PLAY_DELAY_DEFAULT_MS;
}
export function formatAutoPlayDelay(ms) {
return ms === 0 ? '0s' : `${parseFloat((ms / 1000).toFixed(2))}s`;
}
let _nextToastId = 1;
const NOTE_MS = 4500;
export function useExplorer() {
const [ready, setReady] = useState(false);
const [fen, setFen] = useState(DEFAULT_FEN);
const [variant, setVariant] = useState(VARIANTS.STANDARD);
const [locked, setLockedState] = useState(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [moveHistory, setMoveHistory] = useState({ nodes: [], rootChildren: [], rootActiveChild: null, currentId: -1 });
const [startFen, setStartFen] = useState(DEFAULT_FEN);
// The PGN panel stays blank (not even "No moves yet") until Board
// reports a move-tree change for the first time - a fresh session
// with nothing restored from a link or localStorage never fires that
// callback until the player's first action.
const [historyInitialized, setHistoryInitialized] = useState(false);
const [fenError, setFenError] = useState(null);
const [tb, setTb] = useState(() => Tablebase.getViewState());
const [autoplayMetric, setAutoplayMetric] = useState(null);
const [autoplayAvailability, setAutoplayAvailability] = useState(
() => Object.fromEntries(AUTO_PLAY_METRICS.map(m => [m, { disabled: false, title: `Auto-play best ${METRIC_CONFIG[m].label} move` }]))
);
const [lockAutoplayDisabled, setLockAutoplayDisabled] = useState(false);
const [showRootRow, setShowRootRowState] = useState(() => Tablebase.getShowRootRow());
const [autoplayDelayMs, setAutoplayDelayMsState] = useState(() => readAutoPlayDelayMs());
const [settingsOpen, setSettingsOpen] = useState(false);
const [pgnDialogOpen, setPgnDialogOpen] = useState(false);
const [pgnError, setPgnError] = useState('');
const [toasts, setToasts] = useState([]);
const [activeMetric, setActiveMetric] = useState('dtz');
const fenInputRef = useRef(null);
const tbodyRef = useRef(null);
const actionsRef = useRef({});
const bootedRef = useRef(false);
// ── Toasts ──────────────────────────────────────────────────────────
function toast(message, durationMs = 1800) {
const id = _nextToastId++;
setToasts(prev => [...prev, { id, message, leaving: false }]);
setTimeout(() => {
setToasts(prev => prev.map(t => (t.id === id ? { ...t, leaving: true } : t)));
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 400);
}, durationMs);
}
const activeNotesRef = useRef(new Set());
// ── One-time bootstrap ────────────────────────────────────────────────
useEffect(() => {
if (bootedRef.current) return;
bootedRef.current = true;
const hashFen = readHashFen();
const hashVariant = readHashVariant();
const lastGame = readLastGame();
let initialFen, initialVariant, restoreGame;
if (lastGame && hashFen &&
normalizeFen(hashFen) === lastGame.currentFen &&
(hashVariant || VARIANTS.STANDARD) === lastGame.variant) {
initialFen = normalizeFen(lastGame.startFen);
initialVariant = lastGame.variant;
restoreGame = lastGame;
} else if (hashFen) {
initialFen = normalizeFen(hashFen);
initialVariant = hashVariant || VARIANTS.STANDARD;
restoreGame = null;
} else if (lastGame) {
initialFen = normalizeFen(lastGame.startFen);
initialVariant = lastGame.variant;
restoreGame = lastGame;
} else {
initialFen = DEFAULT_FEN;
initialVariant = VARIANTS.STANDARD;
restoreGame = null;
}
const debouncedProbe = debounce(Tablebase.probe, 300);
// ── Auto-play state (refs - pure bookkeeping, no re-render needed) ──
const autoPlayTimerRef = { current: null };
const autoPlayCycleIdRef = { current: 0 };
const autoPlayPacingReadyRef = { current: false };
const autoPlayDataReadyRef = { current: false };
const autoPlayMoveInFlightRef = { current: false };
const autoPlayLockEngagedRef = { current: false };
const autoPlayMetricRef = { current: null }; // mirrors autoplayMetric state for synchronous checks
function bestMovesForArrows(data) {
return {
dtz: data?.dtz_available ? (data.moves_dtz || [])[0] || null : null,
dtc: data?.dtc_available ? (data.moves_dtc || [])[0] || null : null,
dtm: data?.dtm_available ? (data.moves_dtm || [])[0] || null : null,
dtm50: data?.dtm50_available ? (data.moves_dtm50 || [])[0] || null : null,
};
}
function applySetLocked(next, persist = true) {
Board.setLocked(next, persist);
setLockedState(next);
}
function stopAutoPlay() {
const prevMetric = autoPlayMetricRef.current;
autoPlayMetricRef.current = null;
setAutoplayMetric(null);
autoPlayCycleIdRef.current++;
clearTimeout(autoPlayTimerRef.current);
autoPlayTimerRef.current = null;
autoPlayPacingReadyRef.current = false;
autoPlayDataReadyRef.current = false;
if (autoPlayLockEngagedRef.current) {
autoPlayLockEngagedRef.current = false;
applySetLocked(false, false);
}
setLockAutoplayDisabled(false);
return prevMetric;
}
function armAutoPlayWait(dataAlreadyReady) {
const cycleId = ++autoPlayCycleIdRef.current;
autoPlayPacingReadyRef.current = false;
autoPlayDataReadyRef.current = dataAlreadyReady;
clearTimeout(autoPlayTimerRef.current);
autoPlayTimerRef.current = null;
if (!dataAlreadyReady) return;
autoPlayTimerRef.current = setTimeout(() => {
if (cycleId !== autoPlayCycleIdRef.current) return;
autoPlayPacingReadyRef.current = true;
tryPlayAutoPlayMove(cycleId);
}, actionsRef.current.getAutoplayDelayMs());
}
function tryPlayAutoPlayMove(cycleId) {
if (cycleId !== autoPlayCycleIdRef.current) return;
if (!autoPlayMetricRef.current) return;
if (!autoPlayPacingReadyRef.current || !autoPlayDataReadyRef.current) return;
const metric = autoPlayMetricRef.current;
const data = Tablebase.getLastData();
if (!data) return;
const moves = data[METRIC_CONFIG[metric].dataKey] || [];
const best = moves.find(m => m.outcome !== 'unknown' && m.available !== false);
if (!best) { stopAutoPlay(); return; }
autoPlayMoveInFlightRef.current = true;
const played = Board.playMove(best.san);
autoPlayMoveInFlightRef.current = false;
if (!played) { stopAutoPlay(); return; }
if (best.is_mate || best.outcome === 'draw') { stopAutoPlay(); return; }
armAutoPlayWait(false);
}
function onAutoPlayResult() {
if (!autoPlayMetricRef.current || autoPlayDataReadyRef.current) return;
armAutoPlayWait(true);
}
function startAutoPlay(metric) {
if (!METRIC_CONFIG[metric]) return;
stopAutoPlay();
autoPlayMetricRef.current = metric;
setAutoplayMetric(metric);
if (!Board.isLocked()) {
autoPlayLockEngagedRef.current = true;
applySetLocked(true, false);
}
setLockAutoplayDisabled(true);
const dataAlreadyReady = Tablebase.getLastFen() === Board.currentFen() &&
Tablebase.getLastVariant() === Board.getVariant();
armAutoPlayWait(dataAlreadyReady);
}
function updateAutoplayAvailability(data) {
const availableByMetric = {
dtz: data?.dtz_available, dtc: data?.dtc_available,
dtm: data?.dtm_available, dtm50: data?.dtm50_available,
};
const rootWdlByMetric = {
dtz: data?.wdl,
dtc: Array.isArray(data?.dtc) ? data.dtc[0] : null,
dtm: data?.wdl,
dtm50: Array.isArray(data?.dtm50) ? data.dtm50[0] : null,
};
const next = {};
AUTO_PLAY_METRICS.forEach(metric => {
const unavailable = availableByMetric[metric] === false;
const isDraw = !unavailable && rootWdlByMetric[metric] === 0;
if (unavailable || isDraw) {
if (autoPlayMetricRef.current === metric) stopAutoPlay();
next[metric] = {
disabled: true,
title: unavailable
? `Auto-play unavailable — no ${METRIC_CONFIG[metric].label} table for this material`
: `${METRIC_CONFIG[metric].label} auto-play unavailable — position is a draw`,
};
} else {
next[metric] = { disabled: false, title: `Auto-play best ${METRIC_CONFIG[metric].label} move` };
}
});
setAutoplayAvailability(next);
}
// ── Position-change callback ────────────────────────────────────
function onPositionChange(nextFen, immediate) {
if (autoPlayMetricRef.current && !autoPlayMoveInFlightRef.current) stopAutoPlay();
if (fenInputRef.current) {
fenInputRef.current.value = nextFen;
fenInputRef.current.classList.remove('is-invalid');
}
setFenError(null);
Tablebase.clearErrorLine();
setFen(nextFen);
setVariant(Board.getVariant());
setCanBack(Board.canGoBack());
setCanForward(Board.canGoForward());
writeHash(nextFen, Board.getVariant());
Board.clearArrows();
const count = Board.isInsufficientMaterial() ? 0 : Board.legalMoveCount();
Tablebase.setMoveCount(count > 0 ? `${count} legal move${count === 1 ? '' : 's'}` : '');
const onProbeError = () => setReady(true);
const activeVariant = Board.getVariant();
if (immediate) {
debouncedProbe.cancel();
Tablebase.probe(nextFen, activeVariant, onProbeError);
} else {
debouncedProbe(nextFen, activeVariant, onProbeError);
}
}
Board.setOnMoveHistoryChange((history, sFen) => {
setMoveHistory(history);
setStartFen(sFen);
setHistoryInitialized(true);
setCanBack(Board.canGoBack());
setCanForward(Board.canGoForward());
saveGameState(history, sFen, Board.getVariant());
});
Tablebase.setResultHandler(data => {
Board.drawArrows(bestMovesForArrows(data));
updateAutoplayAvailability(data);
setReady(true);
});
Tablebase.setMoveSelectHandler(san => {
const played = Board.playMove(san);
if (!played) toast('Illegal move — this position may have changed.');
});
Tablebase.setNoteHandler(message => {
const active = activeNotesRef.current;
if (active.has(message)) return;
active.add(message);
setTimeout(() => active.delete(message), NOTE_MS + 400);
toast(message, NOTE_MS);
});
Tablebase.setViewStateHandler(setTb);
// ── Board bootstrap ──────────────────────────────────────────────
Theme.init(Board.reconstruct);
setReady(false);
const boardInit = Board.init(onPositionChange, initialFen, initialVariant);
if (!boardInit.ok) toast(boardInit.reason || 'Invalid starting position — loaded the default position instead.');
if (restoreGame) {
Board.restoreTree(restoreGame.startFen, restoreGame.nodes, restoreGame.rootChildren, restoreGame.rootActiveChild, restoreGame.currentId);
}
Theme.apply();
setVariant(Board.getVariant());
setLockedState(Board.isLocked());
setFen(Board.currentFen());
setStartFen(Board.getStartFen());
setCanBack(Board.canGoBack());
setCanForward(Board.canGoForward());
if (fenInputRef.current) fenInputRef.current.value = Board.currentFen();
// ── Public actions ───────────────────────────────────────────────
function applyFen(rawFen) {
const norm = normalizeFen(rawFen);
const result = Board.setPosition(norm);
if (!result.ok) {
if (fenInputRef.current) fenInputRef.current.classList.add('is-invalid');
setFenError(result.reason || 'Invalid position.');
return;
}
if (fenInputRef.current) fenInputRef.current.classList.remove('is-invalid');
setFenError(null);
Tablebase.clearErrorLine();
}
function setTurn(turnChar) {
const parts = Board.currentFen().split(' ');
if (parts[1] === turnChar) return;
parts[1] = turnChar;
applyFen(parts.join(' '));
}
function copyFen() {
navigator.clipboard.writeText(Board.currentFen())
.then(() => toast('FEN copied!'))
.catch(() => {
fenInputRef.current?.select();
document.execCommand('copy');
toast('FEN copied!');
});
}
function exportPgn() {
const text = buildPgnText();
if (!text) { toast('Play a move to export a PGN.'); return; }
const url = URL.createObjectURL(new Blob([text + '\n'], { type: 'application/x-chess-pgn;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = 'chesstb_game.pgn';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function flip() {
Board.flip();
Board.drawArrows(bestMovesForArrows(Tablebase.getLastData()));
}
function clearBoard() { Board.clear(); }
function goBack() { Board.goBack(); }
function goForward() { Board.goForward(); }
function goToNode(id) { Board.goToNode(id); }
function setLockedAction(next) {
if (autoPlayMetricRef.current !== null) return; // soft-disabled during auto-play
applySetLocked(next);
}
function toggleAutoPlay(metric) {
if (autoPlayMetricRef.current === metric) stopAutoPlay();
else startAutoPlay(metric);
}
function setShowRootRowAction(next) { Tablebase.setShowRootRow(next); setShowRootRowState(next); }
function setChess960Action(next) {
const result = Board.setVariant(next ? VARIANTS.CHESS960 : VARIANTS.STANDARD);
if (!result.ok) { toast(result.reason || "Can't switch modes for this position.", 3000); return; }
setVariant(Board.getVariant());
}
function setAutoplayDelayAction(ms) {
const clamped = clampAutoPlayDelayMs(ms);
Storage.write(LS_AUTOPLAY_DELAY_MS, clamped);
setAutoplayDelayMsState(clamped);
}
function getAutoplayDelayMs() { return actionsRef.current.autoplayDelayMsLive; }
function openPgnDialog() { setPgnDialogOpen(true); setPgnError(''); }
function closePgnDialog() { setPgnDialogOpen(false); }
function parsePgnAction(text) {
const result = parsePgn(text);
if (!result.ok) { setPgnError(result.error); return; }
const loaded = Board.importGame(result.tree, result.variant);
if (!loaded.ok) { setPgnError(loaded.reason); return; }
setPgnError('');
closePgnDialog();
}
// Exposed to components via the stable actionsRef object.
Object.assign(actionsRef.current, {
applyFen, setTurn, copyFen, exportPgn, flip, clearBoard, goBack, goForward, goToNode,
setLockedAction, toggleAutoPlay, setShowRootRowAction, setChess960Action,
setAutoplayDelayAction, getAutoplayDelayMs, onAutoPlayResult, openPgnDialog, closePgnDialog,
parsePgnAction, toast, setActiveMetric,
exportCsv: Tablebase.exportCsv,
autoplayDelayMsLive: readAutoPlayDelayMs(),
});
// Very first probe of the session runs immediately (no debounce).
onPositionChange(Board.currentFen(), true);
}, []);
// Wait for the results to commit and paint before starting the delay.
// Two frames also let a zero-delay run show each position's results.
useEffect(() => {
if (!autoplayMetric || tb.loading) return;
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => actionsRef.current.onAutoPlayResult());
});
return () => cancelAnimationFrame(frame);
}, [tb, autoplayMetric]);
// Keep the ref mirror of autoplayDelayMs current for armAutoPlayWait's
// synchronous read (it can't see React state directly from inside a
// closure captured once at bootstrap).
useEffect(() => {
actionsRef.current.autoplayDelayMsLive = autoplayDelayMs;
}, [autoplayDelayMs]);
// ── Keyboard shortcuts ──────────────────────────────────────────────
useEffect(() => {
function onKeydown(e) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.activeElement?.isContentEditable) return;
if (document.getElementById('promotion-dialog')?.classList.contains('is-visible')) return;
if (pgnDialogOpen) return;
if (settingsOpen) return;
if (e.key === 'ArrowLeft' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goBack(); }
if (e.key === 'ArrowRight' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goForward(); }
if (e.key.toLowerCase() === 'f' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.flip(); }
if (e.key.toLowerCase() === 'c' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.clearBoard(); }
}
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
}, [pgnDialogOpen, settingsOpen]);
// ── hashchange (shared links opened while the tab is open) ───────────
useEffect(() => {
function onHashChange() {
const newFen = readHashFen();
if (!newFen) return;
const newVariant = readHashVariant() || VARIANTS.STANDARD;
const result = Board.setPositionAndVariant(normalizeFen(newFen), newVariant);
if (!result.ok) {
actionsRef.current.toast(result.reason || 'Invalid position in URL.');
writeHash(Board.currentFen(), Board.getVariant());
}
}
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
const actions = useMemo(() => new Proxy({}, {
get: (_t, prop) => (...args) => actionsRef.current[prop]?.(...args),
}), []);
return {
ready, fen, variant, locked, canBack, canForward, moveHistory, startFen, historyInitialized, fenError,
tb, autoplayMetric, autoplayAvailability, lockAutoplayDisabled,
showRootRow, autoplayDelayMs, settingsOpen, setSettingsOpen,
pgnDialogOpen, pgnError, toasts, activeMetric,
fenInputRef, tbodyRef, actions,
};
}