/** * tablebase.js - /probe/stream SSE client and results state * * Owns the SSE client for /probe/stream and all state for the four ranked * move tables (DTZ / DTC / DTM / DTM50): * - setResultHandler(fn) - callback after each successful probe, used for * auto-play. * - setNoteHandler(fn) - callback with the message of a warning or info * dot tapped with touch or pen input. * - setViewStateHandler(fn) - callback with a plain object describing * everything the results panel needs to render (loading/progress/error, * the Root row, and the ranked move rows). Rendering itself is the * caller's responsibility; this module never touches the DOM to draw * the table. * - attachTableEvents(tbody) - wires click-to-play, hover pairing, and * hover-triggered prefetch onto the actual rendered . Returns a * cleanup function. * - Prefetch on hover: warms the cache for the child position of a * hovered move. * - The Root row: the current position's own score for each metric, * included in the view state ahead of the ranked child moves when the * "Show Root Row" setting is on (see setShowRootRow/getShowRootRow). * - A coverage indicator: distinct state when error_code === "missing_table". * - An info flag on draws by insufficient material, and a warning flag on * a cursed win / blessed loss outcome - both carry a tooltip string for * the caller to render. The warning tooltip has a second line when the * DTZ score is rounded. * - CSV export of the current move table. * - A result cache keyed by (variant, FEN), so revisiting an already- * probed position reuses the cached result instead of issuing a new * /probe/stream call. */ import { Board, VARIANTS } from './board.js'; import * as Storage from './storage.js'; // ── Bounded LRU cache ──────────────────────────────────────────────────── const _MAX_CACHE_ENTRIES = 500; class _BoundedCache { constructor(maxSize) { this._maxSize = maxSize; this._map = new Map(); } has(key) { return this._map.has(key); } get(key) { if (!this._map.has(key)) return undefined; const value = this._map.get(key); this._map.delete(key); this._map.set(key, value); // refresh recency return value; } set(key, value) { this._map.delete(key); this._map.set(key, value); if (this._map.size > this._maxSize) { this._map.delete(this._map.keys().next().value); // evict oldest } } delete(key) { this._map.delete(key); } } const Tablebase = (() => { let _currentController = null; let _probeSeq = 0; let _loadingTimer = null; let _pendingProgress = null; let _onMoveSelect = null; let _onNote = null; let _onResult = null; let _onViewState = null; let _lastData = null; let _lastFen = null; // FEN of the currently-applied results let _lastVariant = null; // variant the currently-applied results were probed under let _prefetchCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,child_fen) → probe result data let _resultCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,fen) → last probe result // Hover-prefetch state - module-scoped so probe() can see and cancel an // in-flight prefetch for the exact position it's about to probe itself. let _prefetchTimer = null; let _prefetchController = null; // in-flight prefetch fetch, if any let _prefetchKey = null; // cache key for the timer/fetch above let _prefetchRun = null; // identity token for the current pending/in-flight run let _prefetchPromise = null; // current run's eventual { ok, data|error, aborted } outcome let _prefetchAttached = false; // true while a probe() call is awaiting _prefetchPromise function _cancelPrefetch() { clearTimeout(_prefetchTimer); _prefetchTimer = null; if (_prefetchAttached) return; if (_prefetchController) { _prefetchController.abort(); _prefetchController = null; } _prefetchKey = null; _prefetchRun = null; _prefetchPromise = null; } function _cacheKey(fen, variant) { return `${variant || VARIANTS.STANDARD}\u0000${fen}`; } // ── Show Root Row setting ──────────────────────────────────────────── const _LS_SHOW_ROOT_ROW = 'chesstb_show_root_row'; function _readShowRootRowPref() { return Storage.read(_LS_SHOW_ROOT_ROW) === 'true'; } let _showRootRow = _readShowRootRowPref(); const _ROOT_LABEL = 'Root'; // ── WDL / formatting helpers ───────────────────────────────────────── function _wdlToOutcome(wdl) { return { 2:'win', 1:'cursed_win', 0:'draw', '-1':'blessed_loss', '-2':'loss' }[wdl] ?? 'unknown'; } function _plural(n) { return Math.abs(n) === 1 ? 'ply' : 'plies'; } function _pliesText(n) { const a = Math.abs(n); return `${a} ${_plural(a)}`; } function _dtcRootRaw(data) { return { san: _ROOT_LABEL, plies: data.dtc_available ? data.dtc[2] : 0, order: data.dtc_available ? data.dtc[1] : 0, outcome: data.dtc_available ? _wdlToOutcome(data.dtc[0]) : 'not_available', available: data.dtc_available, }; } function _csvPlies(entry) { return entry.available === false ? 'N/A' : entry.plies; } function _isPawnless(fen) { if (!fen) return false; return !/[Pp]/.test(fen.split(' ')[0]); } function _dtcDisplayEntry(entry, ownFen) { if (!entry) return null; if (!entry.available) return { ...entry, orderText: 'N/A' }; return _isPawnless(ownFen) ? { ...entry, orderText: '-' } : { ...entry, orderText: String(entry.order) }; } function _dtcScoreText(entry) { const n = Math.abs(entry.plies); return `${n} ${_plural(n)}/${entry.orderText}`; } const _ROUNDED_NOTE = 'Rounded Score — this could be off by 1 ply.'; function _warningText(entry) { const text = entry.outcome === 'cursed_win' ? 'Cursed Win — a win in principle, but a draw under the 50-move rule.' : entry.outcome === 'blessed_loss' ? 'Blessed Loss — a loss in principle, but a draw under the 50-move rule.' : null; return text && entry.rounded ? `${text}\n${_ROUNDED_NOTE}` : text; } // ── Cell descriptors ────────────────────────────────────────────────── // Same shape/formatting rules as a rendered pair, as plain data; // the caller renders these into cells (see ResultsPanel). `metric` and // `groupClass` drive the CSS grouping; `interactive` gates whether the // cell is clickable/prefetchable (false for the Root row). function _createMoveCells(entry, groupClass, interactive = true, dtcFormat = false) { const metric = groupClass.slice('group-'.length); const headers = interactive ? { move: `th-${metric}-grp th-${metric}-move`, score: `th-${metric}-grp th-${metric}-score` } : null; if (!entry) { return [ { kind: 'move', className: `${groupClass} col-move is-empty-cell`, headers: headers?.move ?? null, text: '—', title: null, interactive: false }, { kind: 'score', className: `${groupClass} col-score is-empty-cell`, headers: headers?.score ?? null, text: '—', title: null, interactive: false }, ]; } let scoreText; if (entry.is_mate) scoreText = 'Checkmate'; else if (entry.draw_reason === 'stalemate') scoreText = 'Stalemate'; else if (entry.outcome === 'draw') scoreText = 'Draw'; else if (entry.outcome === 'unknown') scoreText = 'Unknown'; else if (entry.outcome === 'not_available') scoreText = 'Not Available'; else if (dtcFormat) scoreText = _dtcScoreText(entry); else scoreText = _pliesText(entry.plies); const outcomeLabel = { win: 'Win', loss: 'Loss', cursed_win: 'Cursed win', blessed_loss: 'Blessed loss', }[entry.outcome]; const scoreLabel = outcomeLabel && !entry.is_mate ? `${outcomeLabel}: ${scoreText}` : scoreText; const moveCell = { kind: 'move', className: `${groupClass} col-move${interactive ? '' : ' is-root'}`, headers: headers?.move ?? null, text: entry.san, title: entry.san, interactive, dataSan: interactive ? entry.san : null, dataChildFen: interactive ? (entry.child_fen || '') : null, }; const scoreCell = { kind: 'score', className: `${groupClass} col-score${entry.is_mate ? ' is-mate' : ''}`, headers: headers?.score ?? null, text: scoreText, title: scoreLabel, interactive, dataSan: interactive ? entry.san : null, dataChildFen: interactive ? (entry.child_fen || '') : null, outcome: entry.outcome, warning: _warningText(entry), info: entry.draw_reason === 'insufficient_material' ? 'Draw by Insufficient Material' : null, }; return [moveCell, scoreCell]; } // ── Root row entries ────────────────────────────────────────────────── function _rootEntries(data, fen) { const maxRows = Math.max( (data.moves_dtz || []).length, (data.moves_dtc || []).length, (data.moves_dtm || []).length, (data.moves_dtm50 || []).length, ); const rootIsMate = maxRows === 0 && data.wdl !== 0; const dtzEntry = { san: _ROOT_LABEL, plies: data.dtz_available ? data.dtz : 0, outcome: data.dtz_available ? _wdlToOutcome(data.wdl) : 'not_available', available: data.dtz_available, rounded: data.dtz_rounded === true, is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null, }; const dtcRaw = { ..._dtcRootRaw(data), is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null, }; const dtcEntry = _dtcDisplayEntry(dtcRaw, fen); const dtmEntry = { san: _ROOT_LABEL, plies: data.dtm_available ? data.dtm : 0, outcome: data.dtm_available ? _wdlToOutcome(data.wdl) : 'not_available', available: data.dtm_available, is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null, }; const dtm50Entry = { san: _ROOT_LABEL, plies: data.dtm50_available ? data.dtm50[1] : 0, outcome: data.dtm50_available ? _wdlToOutcome(data.dtm50[0]) : 'not_available', available: data.dtm50_available, is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null, }; return { dtzEntry, dtcEntry, dtmEntry, dtm50Entry }; } // ── View state ──────────────────────────────────────────────────────── // Single object describing everything the results panel renders. // Rebuilt on every meaningful change and pushed to _onViewState. let _viewState = { pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, error: null, // { message, isCoverage } | null moveCount: '', showRootRow: _showRootRow, rootRow: null, // { cells: [...] } | null rows: [], // [{ rank, cells: [...] }] emptyState: true, emptyMessage: 'Choose a position to see its best moves.', }; function _publish(patch) { _viewState = { ..._viewState, ...patch }; if (_onViewState) _onViewState(_viewState); } function _beginLoading(mySeq) { clearTimeout(_loadingTimer); _pendingProgress = { visible: true, pct: null, label: 'Checking position…' }; // Fast and cached responses should not flash through an empty table. // Disable old move actions immediately, then show loading if needed. _publish({ pending: true, error: null, ...(_viewState.loading ? { progress: _pendingProgress } : {}) }); _loadingTimer = setTimeout(() => { if (mySeq !== _probeSeq) return; _blankResults(); _publish({ loading: true, progress: _pendingProgress }); }, 180); } function _rootRowState(data, fen) { if (!_showRootRow) return null; if (!data) { const cells = ['group-dtz', 'group-dtc', 'group-dtm', 'group-dtm50'] .flatMap(g => _createMoveCells(null, g)); return { cells }; } const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(data, fen); const cells = [ ..._createMoveCells(dtzEntry, 'group-dtz', false), ..._createMoveCells(dtcEntry, 'group-dtc', false, true), ..._createMoveCells(dtmEntry, 'group-dtm', false), ..._createMoveCells(dtm50Entry, 'group-dtm50', false), ]; return { cells }; } function _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50) { const maxRows = Math.max(movesDtz.length, movesDtc.length, movesDtm.length, movesDtm50.length); const rows = []; for (let i = 0; i < maxRows; i++) { const dtcEntry = _dtcDisplayEntry(movesDtc[i] || null, (movesDtc[i] || {}).child_fen); const cells = [ ..._createMoveCells(movesDtz[i] || null, 'group-dtz'), ..._createMoveCells(dtcEntry, 'group-dtc', true, true), ..._createMoveCells(movesDtm[i] || null, 'group-dtm'), ..._createMoveCells(movesDtm50[i] || null, 'group-dtm50'), ]; rows.push({ rank: i + 1, cells }); } return rows; } function _blankResults() { _publish({ rootRow: _rootRowState(null, _lastFen), rows: [], emptyState: false, moveCount: _viewState.moveCount, // set client-side from the FEN - untouched here }); } function _isComplete(data) { return !(data && data.summary && data.summary.unknown > 0); } function _applyResults(data, fen, variant) { clearTimeout(_loadingTimer); _lastData = data; _lastFen = fen; _lastVariant = variant; if (_isComplete(data)) _resultCache.set(_cacheKey(fen, variant), data); const movesDtz = data.moves_dtz || []; const movesDtc = data.moves_dtc || []; const movesDtm = data.moves_dtm || []; const movesDtm50 = data.moves_dtm50 || []; const rows = _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50); const maxRows = rows.length; _publish({ pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, error: null, rootRow: _rootRowState(data, fen), rows, emptyState: maxRows === 0, emptyMessage: data.draw_reason === 'insufficient_material' ? 'Draw by insufficient material.' : data.draw_reason === 'stalemate' ? 'Stalemate. No legal moves.' : data.wdl === -2 && maxRows === 0 ? 'Checkmate. No legal moves.' : data.wdl === 0 ? 'Draw.' : 'No ranked moves for this position.', moveCount: maxRows > 0 ? `${maxRows} legal move${maxRows === 1 ? '' : 's'}` : '', }); if (_onResult) _onResult(data); } function _applyCachedResult(data, fen, variant, mySeq) { clearTimeout(_loadingTimer); Promise.resolve().then(() => { if (mySeq !== _probeSeq) return; _applyResults(data, fen, variant); }); } function _reset() { clearTimeout(_loadingTimer); _publish({ pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, rootRow: _rootRowState(null, _lastFen), rows: [], emptyState: false, error: null }); _lastData = null; _lastFen = null; _lastVariant = null; } function _showCoverageError(errData) { const pc = errData.piece_count; const note = pc ? ` (position has ${pc} pieces)` : ''; _publish({ error: { message: `Not in tablebase${note} — load a tablebase with more piece coverage.`, isCoverage: true } }); } function _handleError(errData, onError) { const msg = errData.error || 'Unknown error.'; const isCoverageError = errData.error_code === 'missing_table'; if (isCoverageError) { _showCoverageError(errData); } else { _publish({ error: { message: msg, isCoverage: false } }); } if (onError) onError(msg, isCoverageError); } // ── SSE streaming client ────────────────────────────────────────────── async function* _sseEvents(response) { if (!response.body) throw { error: 'Network error.' }; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop() || ''; for (const rawEvent of events) { const line = rawEvent.trim(); if (!line.startsWith('data: ')) continue; try { yield JSON.parse(line.slice(6)); } catch { /* skip malformed frame */ } } } } async function _probeStream(fen, variant, signal, mySeq) { const response = await fetch('/probe/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fen, variant }), signal, }); if (!response.ok) { const err = await response.json().catch(() => ({ error: 'Network error.' })); throw err; } for await (const evtData of _sseEvents(response)) { if (signal.aborted || mySeq !== _probeSeq) throw new DOMException('Cancelled', 'AbortError'); if (evtData.status === 'probing') { const total = evtData.total; _pendingProgress = { visible: true, pct: total > 0 ? Math.round((evtData.completed / total) * 100) : null, label: total > 0 ? `Evaluating moves · ${evtData.completed}/${total}` : 'Preparing result…', }; if (_viewState.loading) _publish({ progress: _pendingProgress }); } else if (evtData.status === 'done') { return evtData; } else if (evtData.status === 'error') { throw evtData; } } throw { error: 'Stream ended unexpectedly.' }; } function probe(fen, variant, onError) { const mySeq = ++_probeSeq; clearTimeout(_loadingTimer); if (_currentController) { _currentController.abort(); _currentController = null; } const key = _cacheKey(fen, variant); if (_prefetchKey === key) { if (_prefetchController && _prefetchPromise) { _prefetchAttached = true; const waitingOn = _prefetchPromise; _beginLoading(mySeq); waitingOn.then(outcome => { _prefetchAttached = false; if (mySeq !== _probeSeq) return; if (outcome.ok) { _applyResults(outcome.data, fen, variant); } else { _reset(); if (!outcome.aborted) _handleError(outcome.error && outcome.error.error ? outcome.error : { error: 'Cannot reach backend.' }, onError); } }); return; } _cancelPrefetch(); } if (_prefetchCache.has(key)) { const cached = _prefetchCache.get(key); _prefetchCache.delete(key); _applyCachedResult(cached, fen, variant, mySeq); return; } if (_resultCache.has(key)) { _applyCachedResult(_resultCache.get(key), fen, variant, mySeq); return; } _currentController = new AbortController(); const myController = _currentController; const signal = myController.signal; _beginLoading(mySeq); _probeStream(fen, variant, signal, mySeq) .then(data => { if (mySeq !== _probeSeq) return; if (_currentController === myController) _currentController = null; if (data.error) { _reset(); _handleError(data, onError); } else { _applyResults(data, fen, variant); } }) .catch(err => { if (err && err.name === 'AbortError') return; if (mySeq !== _probeSeq) return; if (_currentController === myController) _currentController = null; _reset(); _handleError(err && err.error ? err : { error: 'Cannot reach backend.' }, onError); console.error('[Tablebase]', err); }); } // ── CSV export ──────────────────────────────────────────────────────── function _csvField(value) { const s = String(value); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function exportCsv() { if (!_lastData) return; const dtz = _lastData.moves_dtz || []; const dtc = _lastData.moves_dtc || []; const dtm = _lastData.moves_dtm || []; const dtm50 = _lastData.moves_dtm50 || []; const rows = Math.max(dtz.length, dtc.length, dtm.length, dtm50.length); const lines = []; if (_lastFen) lines.push(`[FEN "${_lastFen}"]`); lines.push('Rank,DTZ Move,DTZ Plies,DTZ Outcome,DTC Move,DTC Plies,DTC Order,DTC Outcome,DTM Move,DTM Plies,DTM Outcome,DTM50 Move,DTM50 Plies,DTM50 Outcome'); if (_showRootRow) { const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(_lastData, _lastFen); lines.push([ '', _ROOT_LABEL, _csvPlies(dtzEntry), dtzEntry.outcome, _ROOT_LABEL, _csvPlies(dtcEntry), dtcEntry.orderText, dtcEntry.outcome, _ROOT_LABEL, _csvPlies(dtmEntry), dtmEntry.outcome, _ROOT_LABEL, _csvPlies(dtm50Entry), dtm50Entry.outcome, ].map(_csvField).join(',')); } for (let i = 0; i < rows; i++) { const d0 = dtz[i], d2 = dtm[i], d3 = dtm50[i]; const d1 = _dtcDisplayEntry(dtc[i] || null, (dtc[i] || {}).child_fen); lines.push([ i + 1, d0 ? d0.san : '', d0 ? _csvPlies(d0) : '', d0 ? d0.outcome : '', d1 ? d1.san : '', d1 ? _csvPlies(d1) : '', d1 ? d1.orderText : '', d1 ? d1.outcome : '', d2 ? d2.san : '', d2 ? _csvPlies(d2) : '', d2 ? d2.outcome : '', d3 ? d3.san : '', d3 ? _csvPlies(d3) : '', d3 ? d3.outcome : '', ].map(_csvField).join(',')); } const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'chesstb_moves.csv'; a.click(); URL.revokeObjectURL(a.href); } // ── Public API ──────────────────────────────────────────────────────── function setMoveSelectHandler(fn) { _onMoveSelect = fn; } function setNoteHandler(fn) { _onNote = fn; } function setResultHandler(fn) { _onResult = fn; } function setViewStateHandler(fn) { _onViewState = fn; if (fn) fn(_viewState); } function getViewState() { return _viewState; } function getLastData() { return _lastData; } function getLastFen() { return _lastFen; } function getLastVariant() { return _lastVariant; } function setShowRootRow(show) { _showRootRow = !!show; Storage.write(_LS_SHOW_ROOT_ROW, _showRootRow); _publish({ showRootRow: _showRootRow, rootRow: _rootRowState(_lastData, _lastFen) }); } function getShowRootRow() { return _showRootRow; } function clearErrorLine() { _publish({ error: null }); } // Sets the legal-move count text ahead of any probe result - the count // is computed client-side from the FEN, not from probe data. function setMoveCount(text) { _publish({ moveCount: text }); } // ── Table DOM wiring (hover pairing + prefetch + click-to-play) ─────── // Attached to the rendered by the results panel component. // Pointer position is tracked so a table rebuild under a stationary // pointer (e.g. a probe result arriving mid-hover) can re-evaluate // what is underneath it. function attachTableEvents(tbody) { if (!tbody) return () => {}; let hoveredPair = []; let lastPointerX = null; let lastPointerY = null; let lastPointerType = ''; function pairCells(cell) { if (cell.classList.contains('col-move')) { const next = cell.nextElementSibling; return next && next.dataset.san ? [cell, next] : [cell]; } if (cell.classList.contains('col-score')) { const prev = cell.previousElementSibling; return prev && prev.dataset.san ? [prev, cell] : [cell]; } return [cell]; } function updatePairHover(target) { const cell = target.closest('td[data-san]'); hoveredPair.forEach(c => c.classList.remove('is-pair-hover')); hoveredPair = []; if (cell) { hoveredPair = pairCells(cell); hoveredPair.forEach(c => c.classList.add('is-pair-hover')); } } function updatePrefetch(target) { const cell = target.closest('td[data-child-fen]'); if (!cell || !cell.dataset.childFen) { _cancelPrefetch(); return; } const childFen = cell.dataset.childFen; const variant = Board.getVariant() || VARIANTS.STANDARD; const key = _cacheKey(childFen, variant); if (key === _prefetchKey) return; _cancelPrefetch(); if (_prefetchCache.has(key) || _resultCache.has(key)) return; _prefetchKey = key; const run = {}; _prefetchRun = run; _prefetchTimer = setTimeout(() => { const controller = new AbortController(); _prefetchController = controller; _prefetchPromise = fetch('/probe/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Prefetch': '1' }, body: JSON.stringify({ fen: childFen, variant }), signal: controller.signal, }) .then(async r => { if (!r.ok) { const err = await r.json().catch(() => ({ error: 'Network error.' })); return { ok: false, error: err }; } for await (const d of _sseEvents(r)) { if (d.status === 'done') { if (_isComplete(d)) _prefetchCache.set(key, d); return { ok: true, data: d }; } if (d.status === 'error') return { ok: false, error: d }; } return { ok: false, error: { error: 'Stream ended unexpectedly.' } }; }) .catch(err => ({ ok: false, error: err, aborted: err && err.name === 'AbortError' })) .finally(() => { if (_prefetchRun === run) { _prefetchController = null; _prefetchKey = null; _prefetchRun = null; _prefetchPromise = null; } }); }, 400); } function reapplyHoverAtPointer() { if (lastPointerX === null) return; const el = document.elementFromPoint(lastPointerX, lastPointerY); if (!el || !tbody.contains(el)) { hoveredPair.forEach(c => c.classList.remove('is-pair-hover')); hoveredPair = []; return; } updatePairHover(el); updatePrefetch(el); } function onPointerdown(e) { lastPointerType = e.pointerType; } function onClick(e) { if (_viewState.pending) return; const type = e.pointerType || lastPointerType; lastPointerType = ''; const dot = e.target.closest('.warning-dot, .info-dot'); if (dot && (type === 'touch' || type === 'pen')) { if (_onNote && dot.title) _onNote(dot.title); return; } const cell = e.target.closest('td[data-san]'); if (cell && _onMoveSelect) _onMoveSelect(cell.dataset.san); } function onKeydown(e) { if (_viewState.pending) return; if (e.key !== 'Enter' && e.key !== ' ') return; const cell = e.target.closest('td[data-san]'); if (cell && _onMoveSelect) { e.preventDefault(); _onMoveSelect(cell.dataset.san); } } function onMousemove(e) { lastPointerX = e.clientX; lastPointerY = e.clientY; } function onMouseover(e) { lastPointerX = e.clientX; lastPointerY = e.clientY; updatePairHover(e.target); updatePrefetch(e.target); } function onMouseleave() { lastPointerX = null; lastPointerY = null; hoveredPair.forEach(c => c.classList.remove('is-pair-hover')); hoveredPair = []; _cancelPrefetch(); } tbody.addEventListener('pointerdown', onPointerdown); tbody.addEventListener('click', onClick); tbody.addEventListener('keydown', onKeydown); tbody.addEventListener('mousemove', onMousemove); tbody.addEventListener('mouseover', onMouseover); tbody.addEventListener('mouseleave', onMouseleave); // The pointer may already be sitting over a cell that just // (re)appeared without itself moving - re-check on every render. reapplyHoverAtPointer(); _reapplyOnNextRender = reapplyHoverAtPointer; return () => { tbody.removeEventListener('pointerdown', onPointerdown); tbody.removeEventListener('click', onClick); tbody.removeEventListener('keydown', onKeydown); tbody.removeEventListener('mousemove', onMousemove); tbody.removeEventListener('mouseover', onMouseover); tbody.removeEventListener('mouseleave', onMouseleave); if (_reapplyOnNextRender === reapplyHoverAtPointer) _reapplyOnNextRender = () => {}; }; } let _reapplyOnNextRender = () => {}; // Called by the results panel after each render commits, so hover // state re-syncs against the freshly-rendered cells. function notifyTableRendered() { _reapplyOnNextRender(); } return { probe, setMoveSelectHandler, setNoteHandler, setResultHandler, getLastData, getLastFen, getLastVariant, setShowRootRow, getShowRootRow, clearErrorLine, setMoveCount, exportCsv, setViewStateHandler, getViewState, attachTableEvents, notifyTableRendered, }; })(); export { Tablebase };