This commit is contained in:
Maciej Caderek
2022-02-14 00:00:41 +01:00
parent 6274236ac7
commit e0b79a7071
26 changed files with 608 additions and 358 deletions

View File

@@ -1,10 +1,10 @@
import { BoardConfig, PiecesStyle, Position } from "./../types"; import { BoardConfig, Header, PiecesStyle, Position } from "./../types";
import { Style, BoardStyle } from "../types"; import { Style, BoardStyle } from "../types";
import drawRectangle from "./layers/drawRectangle"; import drawRectangle from "./layers/drawRectangle";
import drawCoords from "./layers/drawCoords"; import drawCoords from "./layers/drawCoords";
import drawMoveIndicators from "./layers/drawMoveIndicators"; import drawMoveIndicators from "./layers/drawMoveIndicators";
import drawPieces from "./layers/drawPieces"; import drawPieces from "./layers/drawPieces";
import drawHeader from "./layers/drawHeader.ts"; import drawHeader from "./layers/drawHeader";
import drawExtraInfo from "./layers/drawExtraInfo"; import drawExtraInfo from "./layers/drawExtraInfo";
import boards from "./styles-board"; import boards from "./styles-board";
@@ -20,6 +20,22 @@ const defaultConfig: BoardConfig = {
showChecks: true, showChecks: true,
showCoords: true, showCoords: true,
flipped: false, flipped: false,
anonymous: false,
};
const defaultHeader: Header = {
White: "White",
Black: "Black",
WhitePretty: "White",
BlackPretty: "Black",
WhiteElo: null,
BlackElo: null,
Date: null,
DatePretty: null,
Event: null,
Round: null,
Site: null,
Result: null,
}; };
class Board { class Board {
@@ -34,7 +50,7 @@ class Board {
private margin: number = 0; private margin: number = 0;
private style: Style = boards.standard; private style: Style = boards.standard;
private header: { [key: string]: string | undefined } = {}; private header: Header = defaultHeader;
private lastPosition: Position | null = null; private lastPosition: Position | null = null;
private background: HTMLCanvasElement | null = null; private background: HTMLCanvasElement | null = null;
private currentScreen: "title" | "move" = "move"; private currentScreen: "title" | "move" = "move";
@@ -158,7 +174,19 @@ class Board {
return this; return this;
} }
async titleFrame(header: { [key: string]: string | undefined }) { private getFinalHeader() {
return this.cfg.anonymous
? {
...this.header,
White: "Anonymous",
Black: "Anonymous",
WhitePretty: "Anonymous",
BlackPretty: "Anonymous",
}
: this.header;
}
async titleFrame(header: Header) {
this.currentScreen = "title"; this.currentScreen = "title";
this.header = header; this.header = header;
@@ -168,7 +196,7 @@ class Board {
this.scale, this.scale,
this.margin, this.margin,
this.style, this.style,
header this.getFinalHeader()
); );
} }
@@ -224,13 +252,10 @@ class Board {
this.background = canvas; this.background = canvas;
} }
async frame( async frame(position: Position | null, header?: Header) {
position: Position | null,
header: { [key: string]: string | undefined }
) {
this.currentScreen = "move"; this.currentScreen = "move";
this.lastPosition = position; this.lastPosition = position;
this.header = header; this.header = header ?? this.header;
this.tempCtx.clearRect(0, 0, this.size, this.size); this.tempCtx.clearRect(0, 0, this.size, this.size);
@@ -289,7 +314,7 @@ class Board {
this.scale, this.scale,
this.margin, this.margin,
this.style, this.style,
this.header, this.getFinalHeader(),
this.cfg.flipped, this.cfg.flipped,
this.lastPosition this.lastPosition
); );

View File

@@ -16,7 +16,7 @@ const drawCoords = (
) => { ) => {
const scale = size / 1024; const scale = size / 1024;
if (scale <= 0.25) { if (scale <= 0.32) {
return; return;
} }

View File

@@ -1,4 +1,4 @@
import { Style, Position } from "./../../types"; import { Style, Position, Header } from "./../../types";
import drawText from "./drawText"; import drawText from "./drawText";
const chessFontMapping: { [key: string]: string } = { const chessFontMapping: { [key: string]: string } = {
@@ -17,7 +17,7 @@ const drawExtraInfo = async (
scale: number, scale: number,
margin: number, margin: number,
style: Style, style: Style,
data: { [key: string]: string | undefined }, data: Header,
flipped: boolean, flipped: boolean,
position: Position position: Position
) => { ) => {
@@ -32,7 +32,7 @@ const drawExtraInfo = async (
{ {
const w = drawText( const w = drawText(
ctx, ctx,
data.White ?? "White", data.White === "Anonymous" ? "White" : data.White,
"Ubuntu", "Ubuntu",
fontSize, fontSize,
700, 700,
@@ -41,8 +41,7 @@ const drawExtraInfo = async (
"left" "left"
); );
const elo = const elo = data.WhiteElo ? ` ${data.WhiteElo}` : "";
data.WhiteElo && data.WhiteElo !== "?" ? ` ${data.WhiteElo}` : "";
drawText( drawText(
ctx, ctx,
@@ -59,7 +58,7 @@ const drawExtraInfo = async (
{ {
const w = drawText( const w = drawText(
ctx, ctx,
data.Black ?? "Black", data.Black === "Anonymous" ? "Black" : data.Black,
"Ubuntu", "Ubuntu",
fontSize, fontSize,
700, 700,
@@ -68,8 +67,7 @@ const drawExtraInfo = async (
"left" "left"
); );
const elo = const elo = data.BlackElo ? ` ${data.BlackElo}` : "";
data.BlackElo && data.BlackElo !== "?" ? ` ${data.BlackElo}` : "";
drawText( drawText(
ctx, ctx,

View File

@@ -1,53 +1,14 @@
import { Style } from "./../../types"; import { Header, Style } from "../../types";
import drawRectangle from "./drawRectangle"; import drawRectangle from "./drawRectangle";
import drawText from "./drawText"; import drawText from "./drawText";
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const formatDate = (date: string) => {
const [y, m, d] = date.split(".").map(Number);
const month = Number.isNaN(m) ? null : MONTHS[m - 1];
const day = Number.isNaN(d) || month === null ? null : d;
const year = Number.isNaN(y) ? null : y;
return month && day && year
? `${month} ${day}, ${year}`
: month && year
? `${month} ${year}`
: year
? String(year)
: "";
};
const formatName = (name: string) => {
return name
.split(",")
.map((x) => x.trim())
.reverse()
.join(" ");
};
const drawHeader = async ( const drawHeader = async (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
size: number, size: number,
scale: number, scale: number,
margin: number, margin: number,
style: Style, style: Style,
data: { [key: string]: string | undefined } data: Header
) => { ) => {
ctx.clearRect(0, 0, size, size); ctx.clearRect(0, 0, size, size);
await drawRectangle(ctx, size, size + margin * 2, 0, 0, style.border); await drawRectangle(ctx, size, size + margin * 2, 0, 0, style.border);
@@ -55,11 +16,11 @@ const drawHeader = async (
const font = "Ubuntu"; const font = "Ubuntu";
const allSizes = [ const allSizes = [
{ key: "White", line: 60 * scale, fontSize: 42 * scale, n: 0 }, { key: "WhitePretty", line: 60 * scale, fontSize: 42 * scale, n: 0 },
{ key: "Black", line: 60 * scale, fontSize: 42 * scale, n: 2 }, { key: "BlackPretty", line: 60 * scale, fontSize: 42 * scale, n: 2 },
{ key: "Event", line: 30 * scale, fontSize: 20 * scale, n: 4 }, { key: "Event", line: 30 * scale, fontSize: 20 * scale, n: 4 },
{ key: "Round", line: 30 * scale, fontSize: 20 * scale, n: 5 }, { key: "Round", line: 30 * scale, fontSize: 20 * scale, n: 5 },
{ key: "Date", line: 30 * scale, fontSize: 20 * scale, n: 7 }, { key: "DatePretty", line: 30 * scale, fontSize: 20 * scale, n: 7 },
{ key: "Site", line: 30 * scale, fontSize: 20 * scale, n: 8 }, { key: "Site", line: 30 * scale, fontSize: 20 * scale, n: 8 },
]; ];
@@ -67,16 +28,16 @@ const drawHeader = async (
const sizes = allSizes.filter(({ key }) => keys.has(key)); const sizes = allSizes.filter(({ key }) => keys.has(key));
if (data.White && data.Black) { if (data.WhitePretty && data.BlackPretty) {
sizes.push({ key: "vs", line: 50, fontSize: 20, n: 1 }); sizes.push({ key: "vs", line: 50 * scale, fontSize: 20 * scale, n: 1 });
} }
if (data.Event || data.Round) { if (data.Event || data.Round) {
sizes.push({ key: "margin", line: 100, fontSize: 0, n: 3 }); sizes.push({ key: "margin", line: 100 * scale, fontSize: 0, n: 3 });
} }
if (data.Date || data.Site) { if (data.Date || data.Site) {
const line = data.Event || data.Round ? 20 : 100; const line = data.Event || data.Round ? 20 * scale : 100 * scale;
sizes.push({ key: "margin", line, fontSize: 0, n: 6 }); sizes.push({ key: "margin", line, fontSize: 0, n: 6 });
} }
@@ -101,17 +62,10 @@ const drawHeader = async (
return; return;
} }
const item = data[key]; const item = data[key as keyof Header];
if (item) { if (item) {
const text = const text = key === "Round" ? `Round ${item}` : item;
key === "Date"
? formatDate(item)
: key === "Black" || key === "White"
? formatName(item)
: key === "Round"
? `Round ${item}`
: item;
const y = fromTop + line / 2; const y = fromTop + line / 2;

View File

@@ -1,6 +1,7 @@
import { BoardConfig } from "./../types"; import { BoardConfig, Size } from "./../types";
import Board from "../board/Board"; import Board from "../board/Board";
import Game from "../game/Game"; import Game from "../game/Game";
import sizeToPX from "./sizeToPX";
import GIF from "./GIF"; import GIF from "./GIF";
import WebM from "./WebM"; import WebM from "./WebM";
import MP4 from "./MP4"; import MP4 from "./MP4";
@@ -16,10 +17,11 @@ const getData = (board: Board, encoder: GIF | WebM | MP4) => {
const createAnimation = async ( const createAnimation = async (
pgn: string, pgn: string,
boardConfig: BoardConfig, boardConfig: BoardConfig,
format: "GIF" | "WebM" | "MP4" format: "GIF" | "WebM" | "MP4",
size: Size
) => { ) => {
const game = new Game().loadPGN(pgn); const game = new Game().loadPGN(pgn);
const board = new Board(boardConfig); const board = new Board({ ...boardConfig, size: sizeToPX[size] });
const encoder = const encoder =
format === "GIF" format === "GIF"
? new GIF(board.width, board.height, true) ? new GIF(board.width, board.height, true)

View File

@@ -10,7 +10,6 @@ const createImage = async (
boardConfig: BoardConfig, boardConfig: BoardConfig,
size: Size size: Size
) => { ) => {
console.log({ fen, pgn, ply, size });
const game = new Game(); const game = new Game();
if (pgn) { if (pgn) {

View File

@@ -1,5 +1,5 @@
const sizeToPX = { const sizeToPX = {
XS: 256, XS: 360,
S: 512, S: 512,
M: 720, M: 720,
L: 1024, L: 1024,

View File

@@ -1,6 +1,7 @@
import { PieceType, PieceColor, BoardData, Position } from "../types"; import { PieceType, PieceColor, BoardData, Position } from "../types";
import { Chess, ChessInstance } from "chess.js"; import { Chess, ChessInstance } from "chess.js";
import { cleanPGN } from "./PGNHelpers"; import { cleanPGN } from "./PGNHelpers";
import { formatDate, formatName } from "../utils/formatters";
const MATERIAL_VALUE: Map<PieceType, number> = new Map([ const MATERIAL_VALUE: Map<PieceType, number> = new Map([
["q", 9], ["q", 9],
@@ -10,6 +11,13 @@ const MATERIAL_VALUE: Map<PieceType, number> = new Map([
["p", 1], ["p", 1],
]); ]);
const prepareHeaderEntry = (
entry: string | undefined,
ifEmpty: null | string = null
) => {
return !entry || entry === "?" ? ifEmpty : entry;
};
class Game { class Game {
private positions: Position[] = []; private positions: Position[] = [];
private game: ChessInstance = new Chess(); private game: ChessInstance = new Chess();
@@ -149,7 +157,30 @@ class Game {
} }
get header() { get header() {
return this.game.header(); const header = this.game.header();
const white = prepareHeaderEntry(header.White, "Anonymous") as string;
const black = prepareHeaderEntry(header.Black, "Anonymous") as string;
const date = prepareHeaderEntry(header.Date);
return {
White: white,
Black: black,
WhitePretty: formatName(white),
BlackPretty: formatName(black),
WhiteElo: prepareHeaderEntry(header.WhiteElo),
BlackElo: prepareHeaderEntry(header.BlackElo),
Date: date,
DatePretty: date === null ? null : formatDate(date),
Event: prepareHeaderEntry(header.Event),
Round: prepareHeaderEntry(header.Round),
Site: prepareHeaderEntry(header.Site),
Result: prepareHeaderEntry(header.Result),
};
}
get pgn() {
return this.game.pgn();
} }
getPosition(ply: number) { getPosition(ply: number) {

View File

@@ -1,78 +1,28 @@
import { BoardConfig, BoardStyle, GameConfig, PiecesStyle } from "./types"; import WebFont from "webfontloader";
import * as Hammer from "hammerjs";
import { render } from "solid-js/web";
import { BoardStyle, PiecesStyle } from "./types";
import Board from "./board/Board"; import Board from "./board/Board";
import Game from "./game/Game"; import Game from "./game/Game";
import pgns from "./test-data/pgns";
import createAnimation from "./encoders/createAnimation";
// import { decompressPGN } from "./game/PGNHelpers";
import WebFont from "webfontloader";
import Player from "./player/Player"; import Player from "./player/Player";
import * as Hammer from "hammerjs"; import App from "./ui/App";
// import Moves from "./ui/Moves";
// import Controls from "./ui/Controls";
import { state, setState } from "./state"; import { state, setState } from "./state";
import { render } from "solid-js/web";
import App from "./ui/App";
import download from "./utils/download";
import createImage from "./encoders/createImage"; import createImage from "./encoders/createImage";
import createAnimation from "./encoders/createAnimation";
const boardConfig: BoardConfig = { import readFile from "./utils/readFile";
size: 1024, import download from "./utils/download";
tiles: 8, import { compressPGN } from "./game/PGNHelpers";
boardStyle: "calm", import extractUrlData from "./persistance/extractUrlData";
piecesStyle: "tatiana",
showBorder: true,
showExtraInfo: true,
showMaterial: true,
showMoveIndicator: true,
showChecks: true,
showCoords: true,
flipped: false,
};
const gameConfig: GameConfig = {
titleScreen: true,
fromPly: null,
toPly: null,
loop: true,
format: "GIF",
picSize: "M",
animationSize: "M",
};
const createDownloadLink = async (pgn: string, boardConfig: BoardConfig) => {
const file = await createAnimation(pgn, { ...boardConfig, size: 720 }, "MP4");
const link = document.createElement("a");
link.innerText = "DOWNLOAD";
link.setAttribute("href", URL.createObjectURL(file));
link.setAttribute("download", file.name);
return link;
};
console.log(createDownloadLink.name);
const main = async () => { const main = async () => {
// window.location.hash = const board = new Board(state.boardConfig);
// "#QiBEdWtlIEthcmwgLyBDb3VudCBJc291YXJkCkQgMTg1OC4/Py4/PwpFIFBhcmlzClIgMS0wClMgUGFyaXMgRlJBClcgUGF1bCBNb3JwaHkKCmU0IGU1IE5mMyBkNiBkNCBCZzQgZHhlNSBCeGYzIFF4ZjMgZHhlNSBCYzQgTmY2IFFiMyBRZTcgTmMzIGM2IEJnNSBiNSBOeGI1IGN4YjUgQnhiNSsgTmJkNyBPLU8tTyBSZDggUnhkNyBSeGQ3IFJkMSBRZTYgQnhkNysgTnhkNyBRYjgrIE54YjggUmQ4Iw=="; const player = new Player(board, state.gameConfig);
// const hash = window.location.hash; /* Register handlers */
// const pgn = hash === "" ? null : decompressPGN(hash.slice(1));
const pgn = pgns[pgns.length - 1];
// const pgn = pgns[2];
const board = new Board(boardConfig);
// const interval = 1000;
// play(board, gameConfig, pgn, interval);
const player = new Player(board, gameConfig);
const game = new Game().loadPGN(pgn);
setState({
moves: game.getMoves(),
pgn,
ply: 0,
fen: game.getPosition(0).fen,
});
const handlers = { const handlers = {
prev() { prev() {
@@ -93,24 +43,36 @@ const main = async () => {
}, },
toggleBorder() { toggleBorder() {
board.toggleBorder(); board.toggleBorder();
setState("board", "showBorder", !state.board.showBorder); setState("boardConfig", "showBorder", !state.boardConfig.showBorder);
}, },
showBorder() { showBorder() {
board.showBorder(); board.showBorder();
setState("board", "showBorder", true); setState("boardConfig", "showBorder", true);
}, },
hideBorder() { hideBorder() {
board.hideBorder(); board.hideBorder();
setState("board", "showBorder", false); setState("boardConfig", "showBorder", false);
}, },
toggleExtraInfo() { toggleExtraInfo() {
board.toggleExtraInfo(); board.toggleExtraInfo();
setState("board", "showExtraInfo", !state.board.showExtraInfo); setState(
"boardConfig",
"showExtraInfo",
!state.boardConfig.showExtraInfo
);
},
toggleAnonymous() {
setState("boardConfig", "anonymous", !state.boardConfig.anonymous);
board.updateConfig({ anonymous: state.boardConfig.anonymous });
},
toggleTitleScreen() {
setState("gameConfig", "titleScreen", !state.gameConfig.titleScreen);
}, },
flip() { flip() {
console.log("FLIP");
board.flip(); board.flip();
setState("board", "flipped", !state.board.flipped); setState("boardConfig", "flipped", !state.boardConfig.flipped);
}, },
togglePlay() { togglePlay() {
player.playing ? player.pause() : player.play(); player.playing ? player.pause() : player.play();
@@ -121,20 +83,28 @@ const main = async () => {
}, },
changeBoardStyle(style: BoardStyle) { changeBoardStyle(style: BoardStyle) {
board.setStyle(style); board.setStyle(style);
setState("board", "boardStyle", style); setState("boardConfig", "boardStyle", style);
}, },
changePiecesStyle(style: PiecesStyle) { changePiecesStyle(style: PiecesStyle) {
board.setPiecesStyle(style); board.setPiecesStyle(style);
setState("board", "piecesStyle", style); setState("boardConfig", "piecesStyle", style);
}, },
async loadPGN(pgn: string) { async loadPGN(pgn: string) {
const game = new Game().loadPGN(pgn); const game = new Game().loadPGN(pgn);
setState({ pgn, fen: "", moves: game.getMoves() }); setState({
pgn: game.pgn,
fen: "",
moves: game.getMoves(),
ply: 0,
game,
});
window.location.hash = `v1/pgn/${compressPGN(game.pgn)}`;
await player.load(game); await player.load(game);
}, },
async loadFEN(fen: string) { async loadFEN(fen: string) {
const game = new Game().loadFEN(fen); const game = new Game().loadFEN(fen);
setState({ pgn: null, fen, moves: game.getMoves() }); setState({ pgn: "", fen, moves: game.getMoves(), ply: 0, game });
await player.load(game); await player.load(game);
}, },
async downloadImage() { async downloadImage() {
@@ -142,21 +112,24 @@ const main = async () => {
state.fen, state.fen,
state.pgn, state.pgn,
state.ply, state.ply,
state.board, state.boardConfig,
state.game.picSize state.gameConfig.picSize
); );
download(data, "fen", "png"); download(data, "fen", "png");
}, },
async downloadAnimation() {
const data = await createAnimation(
state.pgn,
state.boardConfig,
state.gameConfig.format,
state.gameConfig.animationSize
);
download(data, "game", state.gameConfig.format.toLowerCase());
},
}; };
// @ts-ignore /* Render the page */
window.handlers = handlers;
// @ts-ignore
window.state = state;
/**
* RENDER
**/
render( render(
() => <App handlers={handlers} state={state} />, () => <App handlers={handlers} state={state} />,
document.getElementById("root") as HTMLElement document.getElementById("root") as HTMLElement
@@ -165,31 +138,19 @@ const main = async () => {
const $board = document.querySelector<HTMLImageElement>("#board"); const $board = document.querySelector<HTMLImageElement>("#board");
$board?.appendChild(board.canvas); $board?.appendChild(board.canvas);
// const moves = new Moves($moves as HTMLElement, player).load(game.getMoves()); /* Restore game from the url */
// const controls = new Controls($controls as HTMLElement, handlers).load();
await player.load(game); const { pgn, fen } = extractUrlData();
// @ts-ignore await (pgn
window.load = async (pgn: string) => { ? handlers.loadPGN(pgn)
const game = new Game().loadPGN(pgn); : fen
await player.load(game); ? handlers.loadFEN(fen)
// moves.load(game.getMoves()); : handlers.loadFEN(
// controls.load(); "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
}; ));
// @ts-ignore /* Register events */
window.board = board;
document.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
handlers.prev();
return false;
},
false
);
const keyMapping: { [key: string]: () => void } = { const keyMapping: { [key: string]: () => void } = {
ArrowLeft: handlers.prev, ArrowLeft: handlers.prev,
@@ -202,9 +163,31 @@ const main = async () => {
e: handlers.toggleExtraInfo, e: handlers.toggleExtraInfo,
}; };
document.addEventListener("keydown", ({ key }) => { document.addEventListener("keydown", (e) => {
if (keyMapping[key]) { const target = e.target as HTMLElement | null;
keyMapping[key]();
if (
keyMapping[e.key] &&
target?.nodeName !== "INPUT" &&
target?.nodeName !== "TEXTAREA"
) {
keyMapping[e.key]();
}
});
const preventDefaults = (e: Event) => {
e.preventDefault();
e.stopPropagation();
};
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
document.addEventListener(eventName, preventDefaults, false);
});
document.addEventListener("drop", async (e) => {
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const content = await readFile(e.dataTransfer.files[0]);
handlers.loadPGN(content);
} }
}); });
@@ -222,12 +205,10 @@ const main = async () => {
hammer.on("pinchout", handlers.hideBorder); hammer.on("pinchout", handlers.hideBorder);
hammer.on("tap", handlers.next); hammer.on("tap", handlers.next);
hammer.on("press", handlers.flip); hammer.on("press", handlers.flip);
// createDownloadLink(pgn, boardConfig).then((link) => {
// document.body.appendChild(link);
// });
}; };
/* Boot */
WebFont.load({ WebFont.load({
google: { google: {
families: ["Ubuntu:500,700", "Fira Mono"], families: ["Ubuntu:500,700", "Fira Mono"],

View File

@@ -0,0 +1,27 @@
import { decompressPGN } from "../game/PGNHelpers";
const HEADER_REGEX = /^#v\d+\/(pgn|fen)\//;
const extractUrlData = () => {
const hash = window.location.hash;
if (!HEADER_REGEX.test(hash)) {
return {
pgn: "",
fen: "",
};
}
const [rawVersion, format, ...chunks] = hash.split("/");
const version = Number((rawVersion.match(/\d+/g) ?? [])[0]);
const data = chunks.join("/");
return {
version,
pgn: format === "pgn" ? decompressPGN(data) : "",
fen: format === "fen" ? decodeURI(data) : "",
};
};
export default extractUrlData;

View File

@@ -1,5 +1,6 @@
import isMobile from "is-mobile"; import isMobile from "is-mobile";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import Game from "./game/Game";
import { BoardConfig, GameConfig } from "./types"; import { BoardConfig, GameConfig } from "./types";
const boardConfig: BoardConfig = { const boardConfig: BoardConfig = {
@@ -14,6 +15,7 @@ const boardConfig: BoardConfig = {
showChecks: true, showChecks: true,
showCoords: true, showCoords: true,
flipped: false, flipped: false,
anonymous: false,
}; };
const gameConfig: GameConfig = { const gameConfig: GameConfig = {
@@ -27,9 +29,10 @@ const gameConfig: GameConfig = {
}; };
export type State = { export type State = {
board: BoardConfig; boardConfig: BoardConfig;
game: GameConfig; gameConfig: GameConfig;
pgn: string | null; game: Game;
pgn: string;
fen: string; fen: string;
moves: string[]; moves: string[];
ply: number; ply: number;
@@ -37,9 +40,10 @@ export type State = {
}; };
const initialState: State = { const initialState: State = {
board: boardConfig, boardConfig,
game: gameConfig, gameConfig,
pgn: null, game: new Game(),
pgn: "",
fen: "", fen: "",
moves: [], moves: [],
ply: 0, ply: 0,

View File

@@ -1,3 +0,0 @@
export default [
"r1bqr1k1/pppnppbp/3p1np1/8/1P6/1B2PN2/PBPP1PPP/RN1Q1RK1 w - - 6 8",
];

View File

@@ -1,26 +0,0 @@
const pgns = [
`[Event "Hoogovens Group A"]
[Site "Wijk aan Zee NED"]
[Date "1999.01.20"]
[EventDate "1999.01.16"]
[Round "4"]
[Result "1-0"]
[White "Garry Kasparov"]
[Black "Veselin Topalov"]
[ECO "B07"]
[WhiteElo "2812"]
[BlackElo "2700"]
[PlyCount "87"]
1. e4 d6 2. d4 Nf6 3. Nc3 g6 4. Be3 Bg7 5. Qd2 c6 6. f3 b5
7. Nge2 Nbd7 8. Bh6 Bxh6 9. Qxh6 Bb7 10. a3 e5 11. O-O-O Qe7
12. Kb1 a6 13. Nc1 O-O-O 14. Nb3 exd4 15. Rxd4 c5 16. Rd1 Nb6
17. g3 Kb8 18. Na5 Ba8 19. Bh3 d5 20. Qf4+ Ka7 21. Rhe1 d4
22. Nd5 Nbxd5 23. exd5 Qd6 24. Rxd4 cxd4 25. Re7+ Kb6
26. Qxd4+ Kxa5 27. b4+ Ka4 28. Qc3 Qxd5 29. Ra7 Bb7 30. Rxb7
Qc4 31. Qxf6 Kxa3 32. Qxa6+ Kxb4 33. c3+ Kxc3 34. Qa1+ Kd2
35. Qb2+ Kd1 36. Bf1 Rd2 37. Rd7 Rxd7 38. Bxc4 bxc4 39. Qxh8
Rd3 40. Qa8 c3 41. Qa4+ Ke1 42. f4 f5 43. Kc1 Rd2 44. Qa7 1-0`,
];
export default pgns;

View File

@@ -129,6 +129,7 @@ export type BoardConfig = {
showChecks: boolean; showChecks: boolean;
showCoords: boolean; showCoords: boolean;
flipped: boolean; flipped: boolean;
anonymous: boolean;
}; };
export type Size = "XS" | "S" | "M" | "L" | "XL"; export type Size = "XS" | "S" | "M" | "L" | "XL";
@@ -199,6 +200,8 @@ export type Handlers = {
showBorder(): void; showBorder(): void;
hideBorder(): void; hideBorder(): void;
toggleExtraInfo(): void; toggleExtraInfo(): void;
toggleAnonymous(): void;
toggleTitleScreen(): void;
flip(): void; flip(): void;
togglePlay(): void; togglePlay(): void;
goto(ply: number): void; goto(ply: number): void;
@@ -206,5 +209,21 @@ export type Handlers = {
changePiecesStyle: (style: PiecesStyle) => void; changePiecesStyle: (style: PiecesStyle) => void;
loadPGN: (pgn: string) => Promise<void>; loadPGN: (pgn: string) => Promise<void>;
loadFEN: (fen: string) => Promise<void>; loadFEN: (fen: string) => Promise<void>;
downloadImage: () => void; downloadImage: () => Promise<void>;
downloadAnimation: () => Promise<void>;
};
export type Header = {
White: string;
Black: string;
WhitePretty: string;
BlackPretty: string;
WhiteElo: string | null;
BlackElo: string | null;
Date: string | null;
DatePretty: string | null;
Event: string | null;
Round: string | null;
Site: string | null;
Result: string | null;
}; };

View File

@@ -22,7 +22,21 @@ body {
font-family: Ubuntu, sans-serif; font-family: Ubuntu, sans-serif;
} }
button { .upload {
visibility: hidden;
padding: 0;
}
.upload::before {
content: "UPLOAD PGN FILE";
visibility: visible;
display: inline-block;
text-align: center;
box-sizing: border-box;
}
button,
.upload::before {
padding: 10px; padding: 10px;
font-family: "Ubuntu"; font-family: "Ubuntu";
font-size: 1.5rem; font-size: 1.5rem;
@@ -32,7 +46,8 @@ button {
width: 100%; width: 100%;
} }
button:hover { button:hover,
.upload:hover::before {
background: rgb(0, 207, 162); background: rgb(0, 207, 162);
cursor: pointer; cursor: pointer;
} }
@@ -69,6 +84,11 @@ h3 {
margin: 15px 0 10px 0; margin: 15px 0 10px 0;
} }
hr {
margin-top: 20px;
border-top: solid 1px #ffffff22;
}
.dark { .dark {
background-color: #191d24; background-color: #191d24;
/* background-image: url(src/ui/img/pattern.png); */ /* background-image: url(src/ui/img/pattern.png); */

View File

@@ -25,7 +25,7 @@ const prepareBoards = async () => {
for (const [key, style] of Object.entries(styles) as [BoardStyle, Style][]) { for (const [key, style] of Object.entries(styles) as [BoardStyle, Style][]) {
await board.updateConfig({ boardStyle: key }); await board.updateConfig({ boardStyle: key });
await board.frame(null, {}); await board.frame(null);
board.render(); board.render();
boards.push({ boards.push({
key, key,
@@ -51,12 +51,12 @@ const Boards: Component<{ handlers: Handlers }> = (props) => {
<img <img
class={ class={
"boards__ico" + "boards__ico" +
(state.board.boardStyle === board.key (state.boardConfig.boardStyle === board.key
? " boards__ico--active" ? " boards__ico--active"
: "") : "")
} }
onClick={() => { onClick={() => {
setState("board", "boardStyle", board.key); setState("boardConfig", "boardStyle", board.key);
props.handlers.changeBoardStyle(board.key); props.handlers.changeBoardStyle(board.key);
}} }}
src={board.img} src={board.img}

View File

@@ -20,7 +20,7 @@ const GameTabs: Component<{ moves: readonly string[]; handlers: Handlers }> = (
} }
onClick={() => setTab("moves")} onClick={() => setTab("moves")}
> >
MOVES GAME
</button> </button>
<button <button
class={ class={

View File

@@ -28,3 +28,16 @@
.load__pgn-btn { .load__pgn-btn {
width: 100%; width: 100%;
} }
.load__pgn-file {
margin-top: 20px;
}
.load__pgn-file-info {
font-size: 1.5rem;
}
.load__pgn-file-info p {
margin-top: 10px;
color: #677794;
}

View File

@@ -1,5 +1,6 @@
import { Component, createSignal } from "solid-js"; import { Component, createSignal } from "solid-js";
import { Handlers } from "../../types"; import { Handlers } from "../../types";
import readFile from "../../utils/readFile";
import "./Load.css"; import "./Load.css";
const Load: Component<{ handlers: Handlers; showMoves: () => void }> = ( const Load: Component<{ handlers: Handlers; showMoves: () => void }> = (
@@ -22,12 +23,15 @@ const Load: Component<{ handlers: Handlers; showMoves: () => void }> = (
<button <button
class="load__fen-btn" class="load__fen-btn"
onClick={() => { onClick={() => {
props.handlers.loadFEN(fen()); if (fen()) {
setFEN(""); props.handlers.loadFEN(fen());
setFEN("");
}
}} }}
> >
LOAD FEN LOAD FEN
</button> </button>
<hr />
<textarea <textarea
class="load__pgn-input" class="load__pgn-input"
name="load-pgn" name="load-pgn"
@@ -39,13 +43,33 @@ const Load: Component<{ handlers: Handlers; showMoves: () => void }> = (
<button <button
class="load__pgn-btn" class="load__pgn-btn"
onClick={() => { onClick={() => {
props.handlers.loadPGN(pgn()); if (pgn()) {
setPGN(""); props.handlers.loadPGN(pgn());
props.showMoves(); setPGN("");
props.showMoves();
}
}} }}
> >
LOAD PGN LOAD PGN
</button> </button>
<hr />
<input
class="upload load__pgn-file"
type="file"
accept="application/vnd.chess-pgn,application/x-chess-pgn,.pgn"
onChange={async (e) => {
const target = e.target as HTMLInputElement;
if (target?.files && target.files.length > 0) {
const content = await readFile(target.files[0]);
props.handlers.loadPGN(content);
props.showMoves();
}
}}
></input>
<div className="load__pgn-file-info">
<p>or</p>
<p>drop the PGN file anywhere on the page</p>
</div>
</div> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Component, For } from "solid-js"; import { Component, For, createEffect } from "solid-js";
import chunk_ from "@arrows/array/chunk_"; import chunk_ from "@arrows/array/chunk_";
import { Handlers } from "../../types"; import { Handlers } from "../../types";
import Scrollable from "./reusable/Scrollable"; import Scrollable from "./reusable/Scrollable";
@@ -8,6 +8,10 @@ import { state } from "../../state";
const Moves: Component<{ moves: readonly string[]; handlers: Handlers }> = ( const Moves: Component<{ moves: readonly string[]; handlers: Handlers }> = (
props props
) => { ) => {
createEffect(() => {
document.querySelector(`[data-ply="${state.ply}"]`)?.scrollIntoView();
});
return ( return (
<Scrollable class="moves"> <Scrollable class="moves">
<For each={chunk_(2, props.moves as string[])}> <For each={chunk_(2, props.moves as string[])}>
@@ -23,6 +27,7 @@ const Moves: Component<{ moves: readonly string[]; handlers: Handlers }> = (
"move__ply--current": state.ply === i() * 2 + 1, "move__ply--current": state.ply === i() * 2 + 1,
}} }}
onClick={() => props.handlers.goto(i() * 2 + 1)} onClick={() => props.handlers.goto(i() * 2 + 1)}
data-ply={i() * 2 + 1}
> >
{white} {white}
</span> </span>
@@ -32,6 +37,7 @@ const Moves: Component<{ moves: readonly string[]; handlers: Handlers }> = (
"move__ply--current": state.ply === i() * 2 + 2, "move__ply--current": state.ply === i() * 2 + 2,
}} }}
onClick={() => props.handlers.goto(i() * 2 + 2)} onClick={() => props.handlers.goto(i() * 2 + 2)}
data-ply={i() * 2 + 2}
> >
{black} {black}
</span> </span>

View File

@@ -19,12 +19,12 @@ const Pieces: Component<{ handlers: Handlers }> = (props) => {
<img <img
class={ class={
"pieces__ico" + "pieces__ico" +
(state.board.piecesStyle === item.key (state.boardConfig.piecesStyle === item.key
? " pieces__ico--active" ? " pieces__ico--active"
: "") : "")
} }
onClick={() => { onClick={() => {
setState("board", "piecesStyle", item.key); setState("boardConfig", "piecesStyle", item.key);
props.handlers.changePiecesStyle(item.key); props.handlers.changePiecesStyle(item.key);
}} }}
src={item.img} src={item.img}
@@ -97,7 +97,7 @@ export default Pieces;
// ? " boards__ico--active" // ? " boards__ico--active"
// : "") // : "")
// } // }
// onClick={() => setState("board", "boardStyle", board.key)} // onClick={() => setState("boardConfig", "boardStyle", board.key)}
// src={board.img} // src={board.img}
// title={board.name} // title={board.name}
// /> // />

View File

@@ -45,3 +45,40 @@
margin-top: 0; margin-top: 0;
margin-bottom: 25px; margin-bottom: 25px;
} }
.options__button {
margin: 3px;
padding: 5px;
font-size: 3rem;
background: rgb(0, 173, 136);
text-align: center;
border-radius: 5px;
width: 44px;
height: 44px;
opacity: 0.5;
}
.options__button--active {
opacity: 1;
}
.options__button:hover {
background: rgb(0, 207, 162);
cursor: pointer;
}
.options__button--last {
margin-right: 0px;
}
.options__button--first {
margin-left: 0px;
}
.rotatable {
transition: transform 0.3s;
}
.rotated {
transform: rotate(180deg);
}

View File

@@ -3,6 +3,7 @@ import { Handlers } from "../../types";
import Scrollable from "./reusable/Scrollable"; import Scrollable from "./reusable/Scrollable";
import { state, setState } from "../../state"; import { state, setState } from "../../state";
import "./Share.css"; import "./Share.css";
import download from "../../utils/download";
const Share: Component<{ handlers: Handlers }> = (props) => { const Share: Component<{ handlers: Handlers }> = (props) => {
const [copyId, setCopyId] = createSignal(""); const [copyId, setCopyId] = createSignal("");
@@ -17,41 +18,66 @@ const Share: Component<{ handlers: Handlers }> = (props) => {
<div className="share__view"> <div className="share__view">
<h2 class="header--first">Board options</h2> <h2 class="header--first">Board options</h2>
<button <button
class="controls__button controls__button--first"
onClick={props.handlers.flip} onClick={props.handlers.flip}
title="FLIP" title="FLIP"
classList={{
options__button: true,
"options__button--first": true,
"options__button--active": true,
}}
> >
<i class="las la-sync"></i> <i
classList={{
rotated: state.boardConfig.flipped,
las: true,
"la-sync": true,
rotatable: true,
}}
></i>
</button> </button>
<button <button
class="controls__button" classList={{
options__button: true,
"options__button--active": state.boardConfig.showBorder,
}}
onClick={props.handlers.toggleBorder} onClick={props.handlers.toggleBorder}
title="BORDER" title="BORDER"
> >
<i class="las la-expand"></i> <i class="las la-expand"></i>
</button> </button>
<button <button
class="controls__button" classList={{
options__button: true,
"options__button--active": state.boardConfig.showExtraInfo,
}}
onClick={props.handlers.toggleExtraInfo} onClick={props.handlers.toggleExtraInfo}
title="EXTRA INFO" title="EXTRA INFO"
> >
<i class="las la-info-circle"></i> <i class="las la-info-circle"></i>
</button> </button>
<button <button
class="controls__button" classList={{
onClick={props.handlers.toggleExtraInfo} options__button: true,
title="INCLUDE HEADER" "options__button--active": state.gameConfig.titleScreen,
}}
onClick={props.handlers.toggleTitleScreen}
title="TITLE SCREEN"
> >
<i class="las la-heading"></i> <i class="las la-heading"></i>
</button> </button>
<button <button
class="controls__button controls__button--last" classList={{
onClick={props.handlers.toggleExtraInfo} options__button: true,
"options__button--last": true,
"options__button--active": state.boardConfig.anonymous,
}}
onClick={props.handlers.toggleAnonymous}
title="ANONYMOUS" title="ANONYMOUS"
> >
<i class="las la-user-secret"></i> <i class="las la-user-secret"></i>
</button> </button>
</div> </div>
<hr />
<div className="share__fen"> <div className="share__fen">
<h2>Current position</h2> <h2>Current position</h2>
<input <input
@@ -89,36 +115,36 @@ const Share: Component<{ handlers: Handlers }> = (props) => {
classList={{ classList={{
share__size: true, share__size: true,
"share__size--first": true, "share__size--first": true,
"share__size--active": state.game.picSize === "XS", "share__size--active": state.gameConfig.picSize === "XS",
}} }}
onClick={() => setState("game", "picSize", "XS")} onClick={() => setState("gameConfig", "picSize", "XS")}
> >
XS XS
</button> </button>
<button <button
classList={{ classList={{
share__size: true, share__size: true,
"share__size--active": state.game.picSize === "S", "share__size--active": state.gameConfig.picSize === "S",
}} }}
onClick={() => setState("game", "picSize", "S")} onClick={() => setState("gameConfig", "picSize", "S")}
> >
S S
</button> </button>
<button <button
classList={{ classList={{
share__size: true, share__size: true,
"share__size--active": state.game.picSize === "M", "share__size--active": state.gameConfig.picSize === "M",
}} }}
onClick={() => setState("game", "picSize", "M")} onClick={() => setState("gameConfig", "picSize", "M")}
> >
M M
</button> </button>
<button <button
classList={{ classList={{
share__size: true, share__size: true,
"share__size--active": state.game.picSize === "L", "share__size--active": state.gameConfig.picSize === "L",
}} }}
onClick={() => setState("game", "picSize", "L")} onClick={() => setState("gameConfig", "picSize", "L")}
> >
L L
</button> </button>
@@ -126,9 +152,9 @@ const Share: Component<{ handlers: Handlers }> = (props) => {
classList={{ classList={{
share__size: true, share__size: true,
"share__size--last": true, "share__size--last": true,
"share__size--active": state.game.picSize === "XL", "share__size--active": state.gameConfig.picSize === "XL",
}} }}
onClick={() => setState("game", "picSize", "XL")} onClick={() => setState("gameConfig", "picSize", "XL")}
> >
XL XL
</button> </button>
@@ -140,98 +166,156 @@ const Share: Component<{ handlers: Handlers }> = (props) => {
</button> </button>
</Show> </Show>
</div> </div>
<div class="share__pgn"> <Show when={state.pgn}>
<h2>Game</h2> <hr />
<div class="double"> <div class="share__pgn">
<button class="share__btn">Copy PGN</button> <h2>Game</h2>
<button class="share__btn">Copy link</button> <div class="double">
</div> <button
<div class="double"> class="share__btn"
<button class="share__btn">Export PGN</button> onClick={() => {
<button class="share__btn">Copy markdown</button> navigator.clipboard.writeText(state.pgn);
</div> blinkCopy("pgn");
</div> }}
<Show when={!state.mobile}> >
<div class="share__animation"> {copyId() === "pgn" ? "Copied!" : "Copy PGN"}
<h3>Animation</h3> </button>
<button <button
classList={{ class="share__btn"
share__size: true, onClick={() => {
"share__size--first": true, navigator.clipboard.writeText(window.location.href);
"share__size--active": state.game.animationSize === "XS", blinkCopy("pgn-link");
}} }}
onClick={() => setState("game", "animationSize", "XS")} >
> {copyId() === "pgn-link" ? "Copied!" : "Copy link"}
XS </button>
</button> </div>
<button <div class="double">
classList={{ <button
share__size: true, class="share__btn"
"share__size--active": state.game.animationSize === "S", onClick={() => {
}} const data = new Blob([state.pgn], {
onClick={() => setState("game", "animationSize", "S")} type: "application/vnd.chess-pgn;charset=utf-8",
> });
S download(data, "pgn", "pgn");
</button> }}
<button >
classList={{ Export PGN
share__size: true, </button>
"share__size--active": state.game.animationSize === "M", <button
}} class="share__btn"
onClick={() => setState("game", "animationSize", "M")} onClick={() => {
> const header = state.game.header;
M const w = state.boardConfig.anonymous
</button> ? "Anonymous"
<button : header.WhitePretty;
classList={{ const b = state.boardConfig.anonymous
share__size: true, ? "Anonymous"
"share__size--active": state.game.animationSize === "L", : header.BlackPretty;
}}
onClick={() => setState("game", "animationSize", "L")} const title =
> `${w} vs ${b}` +
L (header.Event ? ` | ${header.Event}` : "") +
</button> (header.Round ? `, Round ${header.Round}` : "") +
<button (header.DatePretty ? ` | ${header.DatePretty}` : "");
classList={{
share__size: true, const md = `[${title}](${window.location.href})`;
"share__size--last": true,
"share__size--active": state.game.animationSize === "XL", navigator.clipboard.writeText(md);
}} blinkCopy("markdown");
onClick={() => setState("game", "animationSize", "XL")} }}
> >
XL {copyId() === "markdown" ? "Copied!" : "Copy markdown"}
</button> </button>
<button </div>
classList={{
share__format: true,
"share__format--first": true,
"share__format--active": state.game.format === "GIF",
}}
onClick={() => setState("game", "format", "GIF")}
>
GIF
</button>
<button
classList={{
share__format: true,
"share__format--active": state.game.format === "MP4",
}}
onClick={() => setState("game", "format", "MP4")}
>
MP4
</button>
<button
classList={{
share__format: true,
"share__format--last": true,
"share__format--active": state.game.format === "WebM",
}}
onClick={() => setState("game", "format", "WebM")}
>
WebM
</button>
<button class="share__create-animation">Save animation</button>
</div> </div>
<Show when={!state.mobile}>
<div class="share__animation">
<h3>Animation</h3>
<button
classList={{
share__size: true,
"share__size--first": true,
"share__size--active": state.gameConfig.animationSize === "XS",
}}
onClick={() => setState("gameConfig", "animationSize", "XS")}
>
XS
</button>
<button
classList={{
share__size: true,
"share__size--active": state.gameConfig.animationSize === "S",
}}
onClick={() => setState("gameConfig", "animationSize", "S")}
>
S
</button>
<button
classList={{
share__size: true,
"share__size--active": state.gameConfig.animationSize === "M",
}}
onClick={() => setState("gameConfig", "animationSize", "M")}
>
M
</button>
<button
classList={{
share__size: true,
"share__size--active": state.gameConfig.animationSize === "L",
}}
onClick={() => setState("gameConfig", "animationSize", "L")}
>
L
</button>
<button
classList={{
share__size: true,
"share__size--last": true,
"share__size--active": state.gameConfig.animationSize === "XL",
}}
onClick={() => setState("gameConfig", "animationSize", "XL")}
>
XL
</button>
<button
classList={{
share__format: true,
"share__format--first": true,
"share__format--active": state.gameConfig.format === "GIF",
}}
onClick={() => setState("gameConfig", "format", "GIF")}
>
GIF
</button>
<button
classList={{
share__format: true,
"share__format--active": state.gameConfig.format === "MP4",
}}
onClick={() => setState("gameConfig", "format", "MP4")}
>
MP4
</button>
<button
classList={{
share__format: true,
"share__format--last": true,
"share__format--active": state.gameConfig.format === "WebM",
}}
onClick={() => setState("gameConfig", "format", "WebM")}
>
WebM
</button>
<button
class="share__create-animation"
onClick={() => props.handlers.downloadAnimation()}
>
Save animation
</button>
</div>
</Show>
</Show> </Show>
</Scrollable> </Scrollable>
); );

View File

@@ -1,4 +1,4 @@
const download = (data: string, name: string, ext: string) => { const download = (data: string | Blob, name: string, ext: string) => {
const url = typeof data === "string" ? data : URL.createObjectURL(data); const url = typeof data === "string" ? data : URL.createObjectURL(data);
const link = document.createElement("a"); const link = document.createElement("a");

40
src/utils/formatters.ts Normal file
View File

@@ -0,0 +1,40 @@
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const formatDate = (date: string) => {
const [y, m, d] = date.split(".").map(Number);
const month = Number.isNaN(m) ? null : MONTHS[m - 1];
const day = Number.isNaN(d) || month === null ? null : d;
const year = Number.isNaN(y) ? null : y;
return month && day && year
? `${month} ${day}, ${year}`
: month && year
? `${month} ${year}`
: year
? String(year)
: "";
};
const formatName = (name: string) => {
return name
.split(",")
.map((x) => x.trim())
.reverse()
.join(" ");
};
export { formatDate, formatName };

15
src/utils/readFile.ts Normal file
View File

@@ -0,0 +1,15 @@
const readFile = (file: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsText(file);
});
};
export default readFile;