Skip to content

Commit c8e17f6

Browse files
authored
[Visualizer Refactor] migrate legacy chess to vite framework (#570)
Also need to bump the version and slightly change the way the game name is passed into the transformer, as we now have a collision on the 'chess' key - verified this works fine as the Open Speil replays all have the name of 'open_spiel_{gameName}' as the name attribute
1 parent 0b474f3 commit c8e17f6

File tree

16 files changed

+4450
-14
lines changed

16 files changed

+4450
-14
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Chess Visualizer</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/main.ts"></script>
12+
</body>
13+
</html>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@kaggle-environments/chess-visualizer",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc && vite build",
9+
"preview": "vite preview"
10+
},
11+
"devDependencies": {
12+
"@types/chess.js": "^0.13.4",
13+
"typescript": "^5.0.0",
14+
"vite": "^5.0.0"
15+
},
16+
"dependencies": {
17+
"@kaggle-environments/core": "workspace:*",
18+
"chess.js": "^1.0.0-beta.6",
19+
"htm": "^3.1.1",
20+
"json-formatter-js": "^2.5.23",
21+
"preact": "^10.13.2"
22+
}
23+
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { LegacyRendererOptions, ReplayData } from '@kaggle-environments/core';
2+
import { Chess } from 'chess.js';
3+
import { MOVES, OPENINGS, pieceImagesSrc } from './consts';
4+
5+
// --- Type Definitions ---
6+
7+
interface ChessObservation {
8+
board: string;
9+
mark: 'white' | 'black';
10+
lastMove?: string;
11+
remainingOverageTime?: number;
12+
opponentRemainingOverageTime?: number;
13+
step?: number;
14+
}
15+
16+
interface ChessAgentStep {
17+
action: string;
18+
reward: number | null;
19+
observation: ChessObservation;
20+
status: string;
21+
info: Record<string, any>;
22+
}
23+
24+
type ChessStep = [ChessAgentStep, ChessAgentStep];
25+
26+
interface ChessReplayData extends ReplayData<ChessStep[]> {
27+
rewards?: (number | null | undefined)[];
28+
info?: {
29+
TeamNames?: string[];
30+
[key: string]: any;
31+
};
32+
viewer?: any;
33+
}
34+
35+
// --- Module Scoped Helpers (Moved outside renderer) ---
36+
37+
const pieceImages: Record<string, HTMLImageElement> = {};
38+
39+
function initializePieceImages() {
40+
const pieces = ['P', 'R', 'N', 'B', 'Q', 'K'];
41+
pieces.forEach((piece) => {
42+
const whiteChar = piece.toLowerCase();
43+
const blackChar = piece.toUpperCase();
44+
const whiteImg = new Image();
45+
const blackImg = new Image();
46+
47+
// Check if pieceImagesSrc is defined to avoid runtime errors if consts file is incomplete
48+
if (pieceImagesSrc) {
49+
whiteImg.src = pieceImagesSrc[whiteChar];
50+
blackImg.src = pieceImagesSrc[blackChar];
51+
pieceImages[whiteChar] = whiteImg;
52+
pieceImages[blackChar] = blackImg;
53+
}
54+
});
55+
}
56+
57+
// Initialize immediately when module loads
58+
initializePieceImages();
59+
60+
function drawPiece(c: CanvasRenderingContext2D, type: string, color: string, x: number, y: number, size: number) {
61+
const pieceCode = color === 'w' ? type.toLowerCase() : type.toUpperCase();
62+
const img = pieceImages[pieceCode];
63+
64+
if (img) {
65+
c.drawImage(img, x, y, size, size);
66+
} else {
67+
const pieceSymbols: Record<string, string> = {
68+
P: '♙',
69+
R: '♖',
70+
N: '♘',
71+
B: '♗',
72+
Q: '♕',
73+
K: '♔',
74+
p: '♟',
75+
r: '♜',
76+
n: '♞',
77+
b: '♝',
78+
q: '♛',
79+
k: '♚',
80+
};
81+
82+
c.font = `${size * 0.8}px Arial`;
83+
c.textAlign = 'center';
84+
c.textBaseline = 'middle';
85+
c.fillStyle = color === 'w' ? 'white' : 'black';
86+
c.fillText(pieceSymbols[pieceCode], x + size / 2, y + size / 2);
87+
}
88+
}
89+
90+
// --- Main Renderer Function ---
91+
92+
export function renderer(context: LegacyRendererOptions<ChessStep[]>) {
93+
const environment = context.replay as ChessReplayData;
94+
const { height = 400, parent, step, width = 400 } = context;
95+
96+
// Common Dimensions.
97+
const canvasSize = Math.min(height, width);
98+
const boardSize = canvasSize * 0.8;
99+
const squareSize = boardSize / 8;
100+
const offset = (canvasSize - boardSize) / 2;
101+
102+
// Canvas Setup.
103+
let canvas = parent.querySelector('canvas') as HTMLCanvasElement | null;
104+
if (!canvas) {
105+
canvas = document.createElement('canvas');
106+
parent.appendChild(canvas);
107+
}
108+
109+
// Create the Download PGN button
110+
let downloadButton = parent.querySelector('#copy-pgn') as HTMLElement | null;
111+
112+
if (!downloadButton && environment.steps.length) {
113+
try {
114+
// Guard: Ensure step 0 exists
115+
const firstStepAgent = environment.steps[0]?.[0];
116+
if (!firstStepAgent) throw new Error('No initial state found');
117+
118+
const board = firstStepAgent.observation.board;
119+
const info = environment.info;
120+
const agent1 = info?.TeamNames?.[0] || 'Agent 1';
121+
const agent2 = info?.TeamNames?.[1] || 'Agent 2';
122+
const game = new Chess();
123+
124+
let result = environment.rewards ?? [];
125+
if (result.some((r) => r === undefined || r === null)) {
126+
result = result.map((r) => (r === undefined || r === null ? 0 : 1));
127+
}
128+
129+
(game as any).header(
130+
'Event',
131+
'FIDE & Google Efficient Chess AI Challenge (https://www.kaggle.com/competitions/fide-google-efficiency-chess-ai-challenge)',
132+
'White',
133+
agent1,
134+
'Black',
135+
agent2
136+
);
137+
138+
const openingIdx = OPENINGS.indexOf(board);
139+
if (openingIdx !== -1 && MOVES[openingIdx]) {
140+
const moves = MOVES[openingIdx].split(' ');
141+
for (let i = 0; i < moves.length; i++) {
142+
const move = moves[i];
143+
game.move({ from: move.slice(0, 2), to: move.slice(2, 4) });
144+
}
145+
}
146+
147+
for (let i = 1; i < environment.steps.length; i++) {
148+
const move = environment.steps[i][(i - 1) % 2].action;
149+
150+
if (!move) continue;
151+
152+
if (move.length === 4) {
153+
game.move({ from: move.slice(0, 2), to: move.slice(2, 4) });
154+
} else if (move.length === 5) {
155+
game.move({
156+
from: move.slice(0, 2),
157+
to: move.slice(2, 4),
158+
promotion: move.slice(4, 5),
159+
});
160+
}
161+
}
162+
163+
let pgn = game.pgn();
164+
165+
if (pgn.indexOf(' 0-0') !== -1) {
166+
pgn = pgn.split(' 0-0')[0];
167+
}
168+
169+
downloadButton = document.createElement('button');
170+
downloadButton.id = 'copy-pgn';
171+
downloadButton.textContent = 'Copy PGN';
172+
downloadButton.style.position = 'absolute';
173+
downloadButton.style.top = '10px';
174+
downloadButton.style.left = '10px';
175+
downloadButton.style.zIndex = '1';
176+
177+
if (!environment.viewer) {
178+
parent.appendChild(downloadButton);
179+
}
180+
181+
downloadButton.addEventListener('click', async () => {
182+
try {
183+
await navigator.clipboard.writeText(pgn);
184+
alert('PGN Copied');
185+
return;
186+
} catch {
187+
console.info('Clipboard access failed. Fall back to display for manual copy.');
188+
}
189+
190+
try {
191+
const btn = document.getElementById('copy-pgn') as HTMLElement;
192+
if (btn) btn.textContent = '';
193+
194+
const pgnDiv = document.createElement('div');
195+
pgnDiv.style.position = 'absolute';
196+
pgnDiv.style.top = '8px';
197+
pgnDiv.style.left = '8px';
198+
pgnDiv.style.zIndex = '2';
199+
pgnDiv.style.border = '1px solid black';
200+
pgnDiv.style.padding = '8px';
201+
pgnDiv.style.background = '#FFFFFF';
202+
pgnDiv.style.fontFamily = 'monospace';
203+
pgnDiv.style.whiteSpace = 'pre-wrap';
204+
205+
const pgnLines = pgn.split('\n');
206+
pgnLines.forEach((line) => {
207+
const lineSpan = document.createElement('span');
208+
lineSpan.textContent = line + '\n';
209+
pgnDiv.appendChild(lineSpan);
210+
});
211+
parent.appendChild(pgnDiv);
212+
213+
const closeButton = document.createElement('span');
214+
closeButton.textContent = '×';
215+
closeButton.style.position = 'absolute';
216+
closeButton.style.top = '5px';
217+
closeButton.style.right = '5px';
218+
closeButton.style.cursor = 'pointer';
219+
closeButton.style.float = 'right';
220+
closeButton.style.fontSize = '16px';
221+
closeButton.style.marginLeft = '5px';
222+
223+
closeButton.addEventListener('click', () => {
224+
if (btn) btn.textContent = 'Copy PGN';
225+
parent.removeChild(pgnDiv);
226+
});
227+
pgnDiv.appendChild(closeButton);
228+
} catch {
229+
console.error('Cannot display div');
230+
alert('PGN cannot be generated');
231+
}
232+
});
233+
} catch (e) {
234+
console.error('Cannot create game pgn');
235+
console.error(e);
236+
}
237+
}
238+
239+
// Canvas setup and reset.
240+
const c = canvas.getContext('2d');
241+
if (!c) return;
242+
243+
canvas.width = canvasSize;
244+
canvas.height = canvasSize;
245+
c.clearRect(0, 0, canvas.width, canvas.height);
246+
247+
// Draw the Chessboard
248+
for (let row = 0; row < 8; row++) {
249+
for (let col = 0; col < 8; col++) {
250+
const x = col * squareSize + offset;
251+
const y = row * squareSize + offset;
252+
253+
c.fillStyle = (row + col) % 2 === 0 ? '#FFCE9E' : '#D18B47';
254+
c.fillRect(x, y, squareSize, squareSize);
255+
}
256+
}
257+
258+
// Guard: Ensure current step data exists
259+
const currentStep = environment.steps[step];
260+
const currentAgent0 = currentStep?.[0];
261+
262+
if (!currentStep || !currentAgent0) return;
263+
264+
// Draw the team names and game status
265+
if (!environment.viewer) {
266+
const info = environment.info;
267+
const agent1 = info?.TeamNames?.[0] || 'Agent 1';
268+
const agent2 = info?.TeamNames?.[1] || 'Agent 2';
269+
270+
const firstGame = currentAgent0.observation.mark == 'white';
271+
272+
const fontSize = Math.round(0.33 * offset);
273+
c.font = `${fontSize}px sans-serif`;
274+
c.fillStyle = '#FFFFFF';
275+
276+
const agent1Reward = currentStep[0]?.reward ?? 0;
277+
const agent2Reward = currentStep[1]?.reward ?? 0;
278+
279+
const charCount = agent1.length + agent2.length + 12;
280+
const title = `${firstGame ? '\u25A0' : '\u25A1'}${agent1} (${agent1Reward}) vs ${
281+
firstGame ? '\u25A1' : '\u25A0'
282+
}${agent2} (${agent2Reward})`;
283+
c.fillText(title, offset + 4 * squareSize - Math.floor((charCount * fontSize) / 4), 40);
284+
}
285+
286+
// Draw the Pieces
287+
const board = currentAgent0.observation.board;
288+
const chess = new Chess(board);
289+
const boardObj = chess.board();
290+
291+
for (let row = 0; row < 8; row++) {
292+
for (let col = 0; col < 8; col++) {
293+
const piece = boardObj[row][col];
294+
if (piece) {
295+
const x = col * squareSize + offset;
296+
const y = row * squareSize + offset;
297+
drawPiece(c, piece.type, piece.color, x, y, squareSize);
298+
}
299+
}
300+
}
301+
}

0 commit comments

Comments
 (0)