elo-worldle/gchessboard.js

3121 lines
127 KiB
JavaScript
Raw Normal View History

2023-04-01 23:11:32 -04:00
/**
* This code is a TypeScript port of code in https://github.com/addaleax/munkres-js.
*
* Some changes include porting to TypeScript, simplifying some loop logic, and
* other formatting and name changes.
*
* Original copyright details:
*
* Copyright 2014 Anna Henningsen (Conversion to JS)
* Copyright 2008 Brian M. Clapper
*
* Original Copyright and License
* ==============================
*
* Copyright 2008-2016 Brian M. Clapper
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const STAR = 1;
const PRIME = 2;
class Munkres {
constructor(costMatrix, padValue) {
Object.defineProperty(this, "C", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "n", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "originalRows", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "originalCols", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "marked", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "rowCovered", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "colCovered", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "Z0Row", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "Z0Col", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
const maxNumColumns = costMatrix.reduce((acc, row) => Math.max(acc, row.length), 0);
this.n = Math.max(costMatrix.length, maxNumColumns);
this.originalRows = costMatrix.length;
this.originalCols = maxNumColumns;
this.C = [];
for (let i = 0; i < this.n; i++) {
const row = costMatrix[i] === undefined ? [] : costMatrix[i].slice();
while (row.length < this.n) {
row.push(padValue || 0);
}
this.C.push(row);
}
this.marked = this._makeMatrix(this.n, 0);
this.rowCovered = Array(this.n).fill(false);
this.colCovered = Array(this.n).fill(false);
}
/**
* Compute the indices for the lowest-cost pairings between rows and columns
* in the database. Returns a list of (row, column) tuples that can be used
* to traverse the matrix.
*
* **WARNING**: This code handles square and rectangular matrices.
* It does *not* handle irregular matrices.
*/
compute() {
let step = 1;
const steps = {
1: this._step1,
2: this._step2,
3: this._step3,
4: this._step4,
5: this._step5,
6: this._step6,
};
while (step < 7) {
const func = steps[step];
step = func.apply(this);
}
const results = [];
for (let i = 0; i < this.originalRows; i++) {
for (let j = 0; j < this.originalCols; j++) {
if (this.marked[i][j] == STAR) {
results.push([i, j]);
}
}
}
return results;
}
/**
* Create an n×n matrix, populating it with the specific value.
*/
_makeMatrix(n, val) {
const matrix = [];
for (let i = 0; i < n; i++) {
const row = [];
for (let j = 0; j < n; j++) {
row.push(val);
}
matrix.push(row);
}
return matrix;
}
/**
* Produce at least one zero in each row by subtracting the smallest
* element of each row from every element in a row. Go to Step 2.
*/
_step1() {
for (let i = 0; i < this.n; i++) {
const minval = Math.min(...this.C[i]);
for (let j = 0; j < this.n; j++) {
this.C[i][j] -= minval;
}
}
return 2;
}
/**
* Assign as many tasks as possible:
* 1. Find a zero in the matrix, and star it. Temporarily mark row and column.
* 2. Find the next zero that is not in an already marked row and column.
* 3. Repeat 1.
* Go to Step 3.
*/
_step2() {
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
if (this.C[i][j] === 0 && !this.rowCovered[i] && !this.colCovered[j]) {
this.marked[i][j] = STAR;
this.rowCovered[i] = true;
this.colCovered[j] = true;
break;
}
}
}
this._clearCovers();
return 3;
}
/**
* Cover each column containing an assignment (starred zero). If K columns
* are covered, the starred zeros describe a complete set of unique
* assignments. In this case, go to DONE, otherwise, go to Step 4.
*/
_step3() {
let count = 0;
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
if (this.marked[i][j] === STAR && !this.colCovered[j]) {
this.colCovered[j] = true;
count++;
}
}
}
return count >= this.n ? 7 : 4;
}
/**
* Find an uncovered zero and prime it. If there is no starred zero
* on that row, go to Step 6. If there is a starred zero on that row,
* cover the row, and uncover the column containing the starred
* zero. Continue doing this, until we find an uncovered zero with no
* starred zero on the same row. Go to Step 5.
*/
_step4() {
let colWithStar = -1;
for (;;) {
const [row, col] = this._findFirstUncoveredZero();
if (row < 0) {
return 6;
}
this.marked[row][col] = PRIME;
colWithStar = this._findStarInRow(row);
if (colWithStar >= 0) {
this.rowCovered[row] = true;
this.colCovered[colWithStar] = false;
}
else {
this.Z0Row = row;
this.Z0Col = col;
return 5;
}
}
}
/**
* Construct a series of alternating primed and starred zeros as
* follows. Let Z0 represent the uncovered primed zero found in Step 4.
* Let Z1 denote the starred zero in the column of Z0 (if any).
* Let Z2 denote the primed zero in the row of Z1 (there will always
* be one). Continue until the series terminates at a primed zero
* that has no starred zero in its column. Unstar each starred zero
* of the series, star each primed zero of the series, erase all
* primes and uncover every line in the matrix. Return to Step 3
*/
_step5() {
let count = 0;
const path = [[this.Z0Row, this.Z0Col]];
for (;;) {
const row = this._findStarInCol(path[count][1]);
if (row < 0) {
break;
}
path.push([row, path[count][1]]);
count++;
const col = this._findPrimeInRow(path[count][0]);
path.push([path[count][0], col]);
count++;
}
for (let i = 0; i <= count; i++) {
const [row, col] = path[i];
// Element at row, col is either starred or primed.
// if star -> unstar
// if prime -> star
this.marked[row][col] = this.marked[row][col] == STAR ? 0 : STAR;
}
this._clearCovers();
this._erasePrimes();
return 3;
}
/**
* From the uncovered elements, find the smallest element.
* Add that value to every element of each covered row, and subtract it
* from every element of each uncovered column. Return to Step 4 without
* altering any stars, primes, or covered lines.
*/
_step6() {
const minval = this._findSmallestUncovered();
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
if (this.rowCovered[i]) {
this.C[i][j] += minval;
}
if (!this.colCovered[j]) {
this.C[i][j] -= minval;
}
}
}
return 4;
}
/**
* Clear all covered matrix cells.
*/
_clearCovers() {
for (let i = 0; i < this.n; i++) {
this.rowCovered[i] = false;
this.colCovered[i] = false;
}
}
/**
* Erase all prime markings.
*/
_erasePrimes() {
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++)
if (this.marked[i][j] === PRIME) {
this.marked[i][j] = 0;
}
}
}
/**
* Find the first uncovered element with value 0. If none found, return [-1, -1].
*/
_findFirstUncoveredZero() {
for (let i = 0; i < this.n; i++)
for (let j = 0; j < this.n; j++)
if (this.C[i][j] === 0 && !this.rowCovered[i] && !this.colCovered[j])
return [i, j];
return [-1, -1];
}
/**
* Find the first starred element in the specified row. Returns
* the column index, or -1 if no starred element was found.
*/
_findStarInRow(row) {
for (let j = 0; j < this.n; j++) {
if (this.marked[row][j] == STAR) {
return j;
}
}
return -1;
}
/**
* Find the first starred element in the specified column. Returns
* the row index, or -1 if no starred element was found.
*/
_findStarInCol(col) {
for (let i = 0; i < this.n; i++) {
if (this.marked[i][col] == STAR) {
return i;
}
}
return -1;
}
/**
* Find the first prime element in the specified row. Returns the column
* index, or -1 if no prime element was found.
*/
_findPrimeInRow(row) {
for (let j = 0; j < this.n; j++) {
if (this.marked[row][j] == PRIME) {
return j;
}
}
return -1;
}
/**
* Find the smallest uncovered value in the matrix.
*/
_findSmallestUncovered() {
let minval = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < this.n; i++) {
for (let j = 0; j < this.n; j++) {
if (!this.rowCovered[i] &&
!this.colCovered[j] &&
minval > this.C[i][j]) {
minval = this.C[i][j];
}
}
}
return minval;
}
}
function munkres(costMatrix, padValue) {
const m = new Munkres(costMatrix, padValue);
return m.compute();
}
/**
* Collection of 0x88-based methods to represent chessboard state.
*
* https://www.chessprogramming.org/0x88
*/
const SIDE_COLORS = ["white", "black"];
// prettier-ignore
const SQUARES_MAP = {
a8: 0, b8: 1, c8: 2, d8: 3, e8: 4, f8: 5, g8: 6, h8: 7,
a7: 16, b7: 17, c7: 18, d7: 19, e7: 20, f7: 21, g7: 22, h7: 23,
a6: 32, b6: 33, c6: 34, d6: 35, e6: 36, f6: 37, g6: 38, h6: 39,
a5: 48, b5: 49, c5: 50, d5: 51, e5: 52, f5: 53, g5: 54, h5: 55,
a4: 64, b4: 65, c4: 66, d4: 67, e4: 68, f4: 69, g4: 70, h4: 71,
a3: 80, b3: 81, c3: 82, d3: 83, e3: 84, f3: 85, g3: 86, h3: 87,
a2: 96, b2: 97, c2: 98, d2: 99, e2: 100, f2: 101, g2: 102, h2: 103,
a1: 112, b1: 113, c1: 114, d1: 115, e1: 116, f1: 117, g1: 118, h1: 119
};
const SQUARES = Object.keys(SQUARES_MAP);
// prettier-ignore
const SQUARE_DISTANCE_TABLE = [
14, 13, 12, 11, 10, 9, 8, 7, 8, 9, 10, 11, 12, 13, 14, 0,
13, 12, 11, 10, 9, 8, 7, 6, 7, 8, 9, 10, 11, 12, 13, 0,
12, 11, 10, 9, 8, 7, 6, 5, 6, 7, 8, 9, 10, 11, 12, 0,
11, 10, 9, 8, 7, 6, 5, 4, 5, 6, 7, 8, 9, 10, 11, 0,
10, 9, 8, 7, 6, 5, 4, 3, 4, 5, 6, 7, 8, 9, 10, 0,
9, 8, 7, 6, 5, 4, 3, 2, 3, 4, 5, 6, 7, 8, 9, 0,
8, 7, 6, 5, 4, 3, 2, 1, 2, 3, 4, 5, 6, 7, 8, 0,
7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 6, 7, 0,
8, 7, 6, 5, 4, 3, 2, 1, 2, 3, 4, 5, 6, 7, 8, 0,
9, 8, 7, 6, 5, 4, 3, 2, 3, 4, 5, 6, 7, 8, 9, 0,
10, 9, 8, 7, 6, 5, 4, 3, 4, 5, 6, 7, 8, 9, 10, 0,
11, 10, 9, 8, 7, 6, 5, 4, 5, 6, 7, 8, 9, 10, 11, 0,
12, 11, 10, 9, 8, 7, 6, 5, 6, 7, 8, 9, 10, 11, 12, 0,
13, 12, 11, 10, 9, 8, 7, 6, 7, 8, 9, 10, 11, 12, 13, 0,
14, 13, 12, 11, 10, 9, 8, 7, 8, 9, 10, 11, 12, 13, 14, 0,
];
const REVERSE_SQUARES_MAP = SQUARES.reduce((acc, key) => {
acc[SQUARES_MAP[key]] = key;
return acc;
}, {});
const FEN_PIECE_TYPE_MAP = {
p: "pawn",
n: "knight",
b: "bishop",
r: "rook",
q: "queen",
k: "king",
};
const REVERSE_FEN_PIECE_TYPE_MAP = Object.keys(FEN_PIECE_TYPE_MAP).reduce((acc, key) => {
acc[FEN_PIECE_TYPE_MAP[key]] = key;
return acc;
}, {});
/**
* Parse a FEN string and return an object that maps squares to pieces.
*
* Also accepts the special string "initial" or "start" to represent
* standard game starting position.
*
* Note that only the first part of the FEN string (piece placement) is
* parsed; any additional components are ignored.
*
* @param fen the FEN string
* @returns an object where key is of type Square (string) and value is
* of type Piece
*/
function getPosition(fen) {
if (fen === "initial" || fen === "start") {
fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR";
}
const parts = fen.split(" ");
const ranks = parts[0].split("/");
if (ranks.length !== 8) {
return undefined;
}
const position = {};
for (let i = 0; i < 8; i++) {
const rank = 8 - i;
let fileOffset = 0;
for (let j = 0; j < ranks[i].length; j++) {
const pieceLetter = ranks[i][j].toLowerCase();
if (pieceLetter in FEN_PIECE_TYPE_MAP) {
const square = (String.fromCharCode(97 + fileOffset) + rank);
position[square] = {
pieceType: FEN_PIECE_TYPE_MAP[pieceLetter],
color: pieceLetter === ranks[i][j] ? "black" : "white",
};
fileOffset += 1;
}
else {
const emptySpaces = parseInt(ranks[i][j]);
if (isNaN(emptySpaces) || emptySpaces === 0 || emptySpaces > 8) {
return undefined;
}
else {
fileOffset += emptySpaces;
}
}
}
if (fileOffset !== 8) {
return undefined;
}
}
return position;
}
/**
* Get FEN string corresponding to Position object. Note that this only returns
* the first (piece placement) component of the FEN string.
*/
function getFen(position) {
const rankSpecs = [];
for (let i = 0; i < 8; i++) {
let rankSpec = "";
let gap = 0;
for (let j = 0; j < 8; j++) {
const square = REVERSE_SQUARES_MAP[16 * i + j];
const piece = position[square];
if (piece !== undefined) {
const pieceStr = REVERSE_FEN_PIECE_TYPE_MAP[piece.pieceType];
if (gap > 0) {
rankSpec += gap;
}
rankSpec += piece.color === "white" ? pieceStr.toUpperCase() : pieceStr;
gap = 0;
}
else {
gap += 1;
}
}
if (gap > 0) {
rankSpec += gap;
}
rankSpecs.push(rankSpec);
}
return rankSpecs.join("/");
}
/**
* Return square identifier for visual index in a grid, depending on
* orientation. If `orientation` is "white", then a8 is on the top
* left (0) and h8 is on the bottom right (63):
*
* a8 ...... .
* . ...... .
* . ...... h1
*
* otherwise h1 is on the top left:
*
* h1 ...... .
* . ...... .
* . ...... a8
*
* https://www.chessprogramming.org/0x88#Coordinate_Transformation
*/
function getSquare(visualIndex, orientation) {
const idx = visualIndex + (visualIndex & ~0x7);
return REVERSE_SQUARES_MAP[orientation === "black" ? 0x77 - idx : idx];
}
/**
* Get the "visual" index for `square` depending on `orientation`.
* If `orientation` is "white", then a8 is on the top left (0) and h8 is
* on the bottom right (63):
*
* a8 ...... .
* . ...... .
* . ...... h1
*
* otherwise h1 is on the top left:
*
* h1 ...... .
* . ...... .
* . ...... a8
*
* https://www.chessprogramming.org/0x88#Coordinate_Transformation
*
* @param square square to convert to visual index.
* @param orientation what side is at the bottom ("white" = a1 on bottom left)
* @returns a visual index for the square in question.
*/
function getVisualIndex(square, orientation) {
const idx = SQUARES_MAP[square];
const orientedIdx = orientation === "black" ? 0x77 - idx : idx;
return (orientedIdx + (orientedIdx & 0x7)) >> 1;
}
/**
* Like `getVisualIndex`, but returns a row and column combination.
*
* @param square square to convert to visual row and column.
* @param orientation what side is at the bottom ("white" = a1 on bottom left)
* @returns an array containing [row, column] for the square in question.
*/
function getVisualRowColumn(square, orientation) {
const idx = getVisualIndex(square, orientation);
return [idx >> 3, idx & 0x7];
}
/**
* https://www.chessprogramming.org/Color_of_a_Square#By_Anti-Diagonal_Index
*/
function getSquareColor(square) {
const idx0x88 = SQUARES_MAP[square];
const idx = (idx0x88 + (idx0x88 & 0x7)) >> 1;
return ((idx * 9) & 8) === 0 ? "light" : "dark";
}
/**
* Type guard to check if `key` (string) is a valid chess square.
*/
function keyIsSquare(key) {
return key !== undefined && key in SQUARES_MAP;
}
/**
* Deep equality check for two Piece objects.
*/
function pieceEqual(a, b) {
return ((a === undefined && b === undefined) ||
(a !== undefined &&
b !== undefined &&
a.color === b.color &&
a.pieceType === b.pieceType));
}
/**
* Type guard for string values that need to conform to a `Side` definition.
*/
function isSide(s) {
return SIDE_COLORS.includes(s);
}
/**
* Deep equality check for Position objects.
*/
function positionsEqual(a, b) {
return SQUARES.every((square) => pieceEqual(a[square], b[square]));
}
function calcPositionDiff(oldPosition, newPosition) {
// Limit old and new positions only to squares that are different
const oldPositionLimited = { ...oldPosition };
const newPositionLimited = { ...newPosition };
Object.keys(newPosition).forEach((k) => {
const square = k;
if (pieceEqual(newPosition[square], oldPosition[square])) {
delete oldPositionLimited[square];
delete newPositionLimited[square];
}
});
const added = [];
const removed = [];
const moved = [];
function groupByPiece(position) {
return Object.entries(position).reduce((groups, [square, piece]) => {
const key = `${piece.color}_${piece.pieceType}`;
if (!(key in groups)) {
groups[key] = { squares: [], piece };
}
groups[key].squares.push(square);
return groups;
}, {});
}
function matchSquares(oldSquares, newSquares) {
const costMatrix = [];
for (let i = 0; i < oldSquares.length; i++) {
const row = [];
for (let j = 0; j < newSquares.length; j++) {
row.push(squareDistance(oldSquares[i], newSquares[j]));
}
costMatrix.push(row);
}
const matches = munkres(costMatrix, 15);
const moved = [];
const added = [];
const removed = [];
const oldSquaresCopy = oldSquares.slice();
const newSquaresCopy = newSquares.slice();
for (const [i, j] of matches || []) {
moved.push({
oldSquare: oldSquaresCopy[i],
newSquare: newSquaresCopy[j],
});
delete oldSquaresCopy[i];
delete newSquaresCopy[j];
}
oldSquaresCopy
.filter((s) => s !== undefined)
.forEach((s) => {
removed.push(s);
});
newSquaresCopy
.filter((s) => s !== undefined)
.forEach((s) => {
added.push(s);
});
return { moved, added, removed };
}
const oldPositionGrouped = groupByPiece(oldPositionLimited);
const newPositionGrouped = groupByPiece(newPositionLimited);
Object.entries(newPositionGrouped).forEach(([k, pieceSquares]) => {
if (k in oldPositionGrouped) {
const matches = matchSquares(oldPositionGrouped[k].squares, pieceSquares.squares);
matches.moved.forEach(({ oldSquare, newSquare }) => {
moved.push({ piece: pieceSquares.piece, oldSquare, newSquare });
});
matches.added.forEach((square) => {
added.push({ piece: pieceSquares.piece, square });
});
matches.removed.forEach((square) => {
removed.push({ piece: pieceSquares.piece, square });
});
delete oldPositionGrouped[k];
}
else {
// Piece only in new position - it is added
pieceSquares.squares.forEach((square) => {
added.push({ piece: pieceSquares.piece, square });
});
}
});
// All pieces in old position now are removed
Object.entries(oldPositionGrouped).forEach(([, pieceSquares]) => {
pieceSquares.squares.forEach((square) => {
removed.push({ piece: pieceSquares.piece, square });
});
});
return { added, removed, moved };
}
function squareDistance(a, b) {
return SQUARE_DISTANCE_TABLE[SQUARES_MAP[a] - SQUARES_MAP[b] + 0x77];
}
/**
* Convenience functions for creating and removing DOM elements.
*/
/**
* Make HTML element, with optional `attributes`, `data` key/values and `classes`
* specified through `options.
*/
function makeHTMLElement(tag, options) {
return addOptionsToElement(document.createElement(tag), options);
}
/**
* Make SVG element, with optional `attributes`, `data` key/values and `classes`
* specified through `options.
*/
function makeSVGElement(tag, options) {
return addOptionsToElement(document.createElementNS("http://www.w3.org/2000/svg", tag), options);
}
function addOptionsToElement(e, options) {
if (options !== undefined) {
for (const key in options.attributes) {
e.setAttribute(key, options.attributes[key]);
}
for (const key in options.data) {
e.dataset[key] = options.data[key];
}
if (options.classes) {
e.classList.add(...options.classes);
}
}
return e;
}
/**
* Wrapper for Typescript `never` type to be used in exhaustive type checks.
*/
// istanbul ignore next
function assertUnreachable(x) {
throw new Error(`Unreachable code reached with value ${x}`);
}
/**
* Visual representation of a chessboard piece.
*/
class BoardPiece {
constructor(container, config) {
Object.defineProperty(this, "piece", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "animationFinished", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_element", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_parentElement", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_explicitPosition", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.piece = config.piece;
this._parentElement = container;
this._element = makeHTMLElement("span", {
attributes: {
role: "presentation",
"aria-hidden": "true",
part: `piece-${BoardPiece.PIECE_CLASS_MAP[this.piece.color][this.piece.pieceType]}`,
},
classes: [
"piece",
BoardPiece.PIECE_CLASS_MAP[this.piece.color][this.piece.pieceType],
],
});
if (config.animation !== undefined) {
this._setAnimation(config.animation);
}
if (config.secondary) {
this._element.classList.add("secondary");
}
container.appendChild(this._element);
}
/**
* Remove piece for square it is contained on, along with any animation
* listeners.
*/
remove(animationDurationMs) {
if (animationDurationMs) {
this._setAnimation({ type: "fade-out", durationMs: animationDurationMs });
}
else {
this._parentElement.removeChild(this._element);
}
}
/**
* Set explicit offset for piece relative to default location in square.
*/
setExplicitPosition(explicitPosition) {
this._explicitPosition = explicitPosition;
const coords = this._getTranslateValues(explicitPosition);
if (coords) {
this._element.style.transform = `translate(${coords.x}, ${coords.y})`;
}
const scaleValue = getComputedStyle(this._element).getPropertyValue(BoardPiece.PIECE_DRAG_SCALE_PROP);
if (scaleValue) {
this._element.style.transform += ` scale(${scaleValue})`;
}
}
/**
* Reset any explicit position set on the piece. If `transition` is true, then
* the change is accompanied with a transition.
*/
resetPosition(animateDurationMs) {
if (animateDurationMs && this._explicitPosition) {
this._setAnimation({
type: "slide-in",
from: this._explicitPosition,
durationMs: animateDurationMs,
});
}
this._element.style.removeProperty("transform");
this._explicitPosition = undefined;
}
/**
* Return explicit position of piece on square, if any.
*/
get explicitPosition() {
return this._explicitPosition;
}
/**
* Finish any animations, if in progress.
*/
finishAnimations() {
this._element.getAnimations().forEach((a) => {
a.finish();
});
}
_getTranslateValues(explicitPosition) {
if (explicitPosition.type === "coordinates") {
const squareDims = this._parentElement.getBoundingClientRect();
const deltaX = explicitPosition.x - squareDims.left - squareDims.width / 2;
const deltaY = explicitPosition.y - squareDims.top - (3 * squareDims.height) / 4;
if (deltaX !== 0 || deltaY !== 0) {
return { x: `${deltaX}px`, y: `${deltaY}px` };
}
}
else {
if (explicitPosition.deltaCols !== 0 ||
explicitPosition.deltaRows !== 0) {
return {
x: `${explicitPosition.deltaCols * 100}%`,
y: `${explicitPosition.deltaRows * 100}%`,
};
}
}
return undefined;
}
_setAnimation(animationSpec) {
let keyframes;
let onfinish;
// Always have exactly one animation running at a time.
this.finishAnimations();
switch (animationSpec.type) {
case "slide-in":
{
const coords = this._getTranslateValues(animationSpec.from);
if (coords) {
keyframes = [
{ transform: `translate(${coords.x}, ${coords.y})` },
{ transform: "none" },
];
this._element.classList.add("moving");
}
onfinish = () => {
this._element.classList.remove("moving");
};
}
break;
case "fade-in":
keyframes = [{ opacity: 0 }, { opacity: 1 }];
break;
case "fade-out":
{
keyframes = [{ opacity: 1 }, { opacity: 0 }];
const elementCopy = this._element;
onfinish = () => {
this._parentElement.removeChild(elementCopy);
};
}
break;
default:
assertUnreachable(animationSpec);
}
if (keyframes !== undefined &&
typeof this._element.animate === "function") {
const animation = this._element.animate(keyframes, {
duration: Math.max(0, animationSpec.durationMs),
});
this.animationFinished = new Promise((resolve) => {
animation.onfinish = () => {
if (onfinish !== undefined) {
onfinish();
}
this.animationFinished = undefined;
resolve();
};
});
}
else if (onfinish !== undefined) {
onfinish();
}
}
}
/**
* Map of piece to background image CSS class name.
*/
Object.defineProperty(BoardPiece, "PIECE_CLASS_MAP", {
enumerable: true,
configurable: true,
writable: true,
value: {
white: {
queen: "wq",
king: "wk",
knight: "wn",
pawn: "wp",
bishop: "wb",
rook: "wr",
},
black: {
queen: "bq",
king: "bk",
knight: "bn",
pawn: "bp",
bishop: "bb",
rook: "br",
},
}
});
/**
* CSS custom property for scale applied to piece while draggging.
* This is overridden per input method within CSS styles.
*/
Object.defineProperty(BoardPiece, "PIECE_DRAG_SCALE_PROP", {
enumerable: true,
configurable: true,
writable: true,
value: "--p-piece-drag-scale"
});
/**
* Visual representation of a chessboard square, along with attributes
* that aid in interactivity (ARIA role, labels etc).
*/
class BoardSquare {
constructor(container, label) {
Object.defineProperty(this, "_tdElement", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_contentElement", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_slotWrapper", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_slotElement", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_label", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_interactive", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_tabbable", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_moveable", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_boardPiece", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_secondaryBoardPiece", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_hasContent", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_hover", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_markedTarget", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_moveState", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this._tdElement = makeHTMLElement("td", { attributes: { role: "cell" } });
this._label = label;
this._contentElement = makeHTMLElement("div", { classes: ["content"] });
this._slotWrapper = makeHTMLElement("div", {
classes: ["slot"],
attributes: { role: "presentation" },
});
this._slotElement = document.createElement("slot");
this._slotWrapper.appendChild(this._slotElement);
this._contentElement.appendChild(this._slotWrapper);
this._updateLabelVisuals();
this._tdElement.appendChild(this._contentElement);
container.appendChild(this._tdElement);
}
/**
* Label associated with the square (depends on orientation of square
* on the board).
*/
get label() {
return this._label;
}
set label(value) {
this._label = value;
this._updateLabelVisuals();
}
/**
* Whether the square is used in an interactive grid. Decides whether
* the square should get visual attributes like tabindex, labels etc.
*/
get interactive() {
return this._interactive;
}
set interactive(value) {
this._interactive = value;
this._moveState = undefined;
// Aria roles
this._tdElement.setAttribute("role", value ? "gridcell" : "cell");
if (value) {
this._contentElement.setAttribute("role", "button");
}
else {
this._contentElement.removeAttribute("role");
}
this._updateTabIndex();
this._updateMoveStateVisuals();
this._updateLabelVisuals();
}
/**
* Whether this square can be tabbed to by the user (tabindex = 0). By default,
* all chessboard squares are focusable but not user-tabbable (tabindex = -1).
*/
get tabbable() {
return this._tabbable;
}
set tabbable(value) {
this._tabbable = value;
this._updateTabIndex();
}
/**
* Whether this square should be marked as containing any slotted content.
*/
get hasContent() {
return !!this._hasContent;
}
set hasContent(value) {
this._hasContent = value;
this._contentElement.classList.toggle("has-content", value);
}
/**
* Whether the piece on this square is moveable through user interaction.
* To be set to true, a piece must actually exist on the square.
*/
get moveable() {
return this._moveable;
}
set moveable(value) {
if (!value || this._boardPiece) {
this._moveable = value;
this._updateMoveStateVisuals();
this._updateLabelVisuals();
}
}
/**
* Whether this square is a valid move target. These are highlighted
* when move is in progress, indicating squares that we can move to.
*/
get moveTarget() {
return this._moveState === "move-target";
}
set moveTarget(value) {
this._moveState = value ? "move-target" : "move-nontarget";
this._updateMoveStateVisuals();
this._updateLabelVisuals();
}
removeMoveState() {
this._moveState = undefined;
this._updateMoveStateVisuals();
this._updateLabelVisuals();
}
/**
* Whether this square is currently a "hover" target: the equivalent of a
* :hover pseudoclass while mousing over a target square, but for drag
* and keyboard moves.
*/
get hover() {
return this._hover;
}
set hover(value) {
this._hover = value;
this._contentElement.classList.toggle("hover", value);
}
/**
* Whether this square is currently a marked destination of a move. This
* is usually shown with a marker or other indicator on the square.
*/
get markedTarget() {
return this._markedTarget;
}
set markedTarget(value) {
this._markedTarget = value;
this._contentElement.classList.toggle("marked-target", value);
}
/**
* Rendered width of element (in integer), used in making drag threshold calculations.
*/
get width() {
return this._contentElement.clientWidth;
}
/**
* Get explicit position of primary piece, if set.
*/
get explicitPiecePosition() {
return this._boardPiece?.explicitPosition;
}
/**
* Focus element associated with square.
*/
focus() {
this._contentElement.focus();
}
/**
* Blur element associated with square.
*/
blur() {
this._contentElement.blur();
}
/**
* Return BoardPiece on this square, if it exists.
*/
get boardPiece() {
return this._boardPiece;
}
/**
* Set primary piece associated with the square. This piece is rendered either
* directly onto the square (default) or optionally, animating in from an
* explicit position `animateFromPosition`.
*
* If the piece being set is the same as the one already present on the
* square, and the new piece is not animating in from anywhere, this will
* be a no-op since the position of the two pieces would otherwise be exactly
* the same.
*/
setPiece(piece, moveable, animation) {
if (!pieceEqual(this._boardPiece?.piece, piece) || animation) {
this.clearPiece(animation?.durationMs);
this._boardPiece = new BoardPiece(this._contentElement, {
piece,
animation,
});
this.moveable = moveable;
this._updateSquareAfterPieceChange();
}
}
clearPiece(animationDurationMs) {
if (this._boardPiece !== undefined) {
this.moveable = false;
this._boardPiece.remove(animationDurationMs);
this._boardPiece = undefined;
this._updateSquareAfterPieceChange();
}
}
/**
* Optionally, squares may have a secondary piece, such as a ghost piece shown
* while dragging. The secondary piece is always shown *behind* the primary
* piece in the DOM.
*/
toggleSecondaryPiece(show) {
if (show && !this._secondaryBoardPiece && this._boardPiece) {
this._secondaryBoardPiece = new BoardPiece(this._contentElement, {
piece: this._boardPiece.piece,
secondary: true,
});
}
if (!show) {
if (this._secondaryBoardPiece !== undefined) {
this._secondaryBoardPiece.remove();
}
this._secondaryBoardPiece = undefined;
}
}
/**
* Mark this square as being interacted with.
*/
startInteraction() {
if (this._boardPiece !== undefined && this.moveable) {
this._moveState = "move-start";
this._updateMoveStateVisuals();
this._updateLabelVisuals();
this._boardPiece.finishAnimations();
}
}
/**
* Set piece to explicit pixel location. Ignore if square has no piece.
*/
displacePiece(x, y) {
this._boardPiece?.setExplicitPosition({ type: "coordinates", x, y });
}
/**
* Set piece back to original location. Ignore if square has no piece.
*/
resetPiecePosition(animateDurationMs) {
this._boardPiece?.resetPosition(animateDurationMs);
}
/**
* Cancel ongoing interaction and reset position.
*/
cancelInteraction(animateDurationMs) {
this._moveState = undefined;
this._updateMoveStateVisuals();
this._updateLabelVisuals();
this.resetPiecePosition(animateDurationMs);
}
_updateLabelVisuals() {
this._contentElement.dataset.square = this.label;
this._contentElement.dataset.squareColor = getSquareColor(this.label);
const labelParts = [
this._boardPiece
? `${this.label}, ${this._boardPiece.piece.color} ${this._boardPiece.piece.pieceType}`
: `${this.label}`,
];
if (this._moveState === "move-start") {
labelParts.push("start of move");
}
if (this._moveState === "move-target") {
labelParts.push("target square");
}
this._contentElement.setAttribute("aria-label", labelParts.join(", "));
this._slotElement.name = this.label;
}
_updateTabIndex() {
if (this.interactive) {
this._contentElement.tabIndex = this.tabbable ? 0 : -1;
}
else {
this._contentElement.removeAttribute("tabindex");
}
}
_updateMoveStateVisuals() {
this._updateInteractiveCssClass("moveable", this.moveable && !this._moveState);
this._updateInteractiveCssClass("move-start", this._moveState === "move-start");
this._updateInteractiveCssClass("move-target", this._moveState === "move-target");
this._contentElement.setAttribute("aria-disabled", (!this._moveState && !this.moveable).toString());
}
_updateInteractiveCssClass(name, value) {
this._contentElement.classList.toggle(name, this.interactive && value);
}
_updateSquareAfterPieceChange() {
this._contentElement.classList.toggle("has-piece", !!this._boardPiece);
// Always cancel ongoing interactions when piece changes
this._moveState = undefined;
this._updateMoveStateVisuals();
// Ensure secondary piece is toggled off if piece is changed
this.toggleSecondaryPiece(false);
// Update label
this._updateLabelVisuals();
}
}
class Board {
/**
* Creates a set of elements representing chessboard squares, as well
* as managing and displaying pieces rendered on the squares.
*/
constructor(initValues, dispatchEvent, shadowRef) {
Object.defineProperty(this, "_table", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_boardSquares", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_dispatchEvent", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_shadowRef", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_orientation", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_turn", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_interactive", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_position", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_boardState", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_tabbableSquare", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_defaultTabbableSquare", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Certain move "finishing" logic is included in `pointerup` (e.g. drags). To
* prevent re-handling this in the `click` handler, we prevent handling of click
* events for a certain period after pointerup.
*/
Object.defineProperty(this, "_preventClickHandling", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// Event handlers
Object.defineProperty(this, "_pointerDownHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_pointerUpHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_pointerMoveHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_clickHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_focusInHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_keyDownHandler", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Duration (in milliseconds) for all animations.
*/
Object.defineProperty(this, "animationDurationMs", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_slotChangeHandler", {
enumerable: true,
configurable: true,
writable: true,
value: (e) => {
if (Board._isSlotElement(e.target) && keyIsSquare(e.target.name)) {
this._getBoardSquare(e.target.name).hasContent =
e.target.assignedElements().length > 0;
}
}
});
Object.defineProperty(this, "_transitionHandler", {
enumerable: true,
configurable: true,
writable: true,
value: (e) => {
// Delete transition-property style at the end of all transitions
if (e.target && e.target.style !== undefined) {
const style = e.target.style;
style.removeProperty("transition-property");
}
}
});
this._boardSquares = new Array(64);
this._orientation = initValues.orientation;
this.animationDurationMs = initValues.animationDurationMs;
this._interactive = false;
this._position = {};
this._boardState = { id: "default" };
this._dispatchEvent = dispatchEvent;
this._shadowRef = shadowRef;
// Bottom left corner
this._defaultTabbableSquare = getSquare(56, initValues.orientation);
this._table = makeHTMLElement("table", {
attributes: {
role: "table",
"aria-label": "Chess board",
},
classes: ["board"],
});
for (let i = 0; i < 8; i++) {
const row = makeHTMLElement("tr", {
attributes: { role: "row" },
});
for (let j = 0; j < 8; j++) {
const idx = 8 * i + j;
const square = getSquare(idx, this.orientation);
this._boardSquares[idx] = new BoardSquare(row, square);
}
this._table.appendChild(row);
}
this._getBoardSquare(this._defaultTabbableSquare).tabbable = true;
this._pointerDownHandler = this._makeEventHandler(this._handlePointerDown);
this._pointerUpHandler = this._makeEventHandler(this._handlePointerUp);
this._pointerMoveHandler = this._makeEventHandler(this._handlePointerMove);
this._clickHandler = this._makeEventHandler(this._handleClick);
this._keyDownHandler = this._makeEventHandler(this._handleKeyDown);
this._focusInHandler = this._makeEventHandler(this._handleFocusIn);
this._table.addEventListener("pointerdown", this._pointerDownHandler);
this._table.addEventListener("click", this._clickHandler);
this._table.addEventListener("focusin", this._focusInHandler);
this._table.addEventListener("keydown", this._keyDownHandler);
this._table.addEventListener("slotchange", this._slotChangeHandler);
this._table.addEventListener("transitionend", this._transitionHandler);
this._table.addEventListener("transitioncancel", this._transitionHandler);
}
/**
* Add event listeners that operate outside shadow DOM (pointer up and move).
* These listeners should be unbound when the element is removed from the DOM.
*/
addGlobalListeners() {
document.addEventListener("pointerup", this._pointerUpHandler);
document.addEventListener("pointermove", this._pointerMoveHandler);
}
/**
* Removes global listeners for pointer up and move.
*/
removeGlobalListeners() {
document.removeEventListener("pointerup", this._pointerUpHandler);
document.removeEventListener("pointermove", this._pointerMoveHandler);
}
/**
* HTML element associated with board.
*/
get element() {
return this._table;
}
/**
* What side's perspective to render squares from (what color appears on
* the bottom as viewed on the screen).
*/
get orientation() {
return this._orientation;
}
set orientation(value) {
this._cancelMove(false);
this._orientation = value;
this._refreshDefaultTabbableSquare();
for (let i = 0; i < 64; i++) {
const square = getSquare(i, value);
const piece = this._position[square];
this._boardSquares[i].label = square;
this._boardSquares[i].tabbable = this.tabbableSquare === square;
if (piece) {
this._boardSquares[i].setPiece(piece, this._pieceMoveable(piece));
}
else {
this._boardSquares[i].clearPiece();
}
}
// Switch focused square, if any, on orientation change
if (this._focusedSquare) {
this._focusTabbableSquare();
}
}
/**
* Whether the grid is interactive. This determines the roles and attributes,
* like tabindex, associated with the grid.
*/
get interactive() {
return this._interactive;
}
set interactive(value) {
this._cancelMove(false);
this._interactive = value;
this._blurTabbableSquare();
this._table.setAttribute("role", value ? "grid" : "table");
this._boardSquares.forEach((s) => {
s.interactive = value;
});
this._resetBoardStateAndMoves();
}
get turn() {
return this._turn;
}
/**
* What side is allowed to move pieces. This may be undefined, in which
* pieces from either side can be moved around.
*/
set turn(value) {
this._cancelMove(false);
this._turn = value;
for (let idx = 0; idx < 64; idx++) {
const square = getSquare(idx, this.orientation);
const piece = this._position[square];
this._boardSquares[idx].moveable = !piece || this._pieceMoveable(piece);
}
}
/**
* Current `Position` object of board.
*/
get position() {
return this._position;
}
set position(value) {
if (!positionsEqual(this._position, value)) {
this._cancelMove(false);
const diff = calcPositionDiff(this._position, value);
this._position = { ...value };
diff.moved.forEach(({ oldSquare }) => {
// Remove all copies of moved piece from starting squares, without animation
this._getBoardSquare(oldSquare).clearPiece();
});
diff.removed.forEach(({ square }) => {
this._getBoardSquare(square).clearPiece(this.animationDurationMs);
});
diff.moved.forEach(({ piece, oldSquare, newSquare }) => {
// Render moved piece at location of old square, and animate in to new square
const startingPosition = this._getStartingPositionForMove(oldSquare, newSquare);
this._getBoardSquare(newSquare).setPiece(piece, this._pieceMoveable(piece), {
type: "slide-in",
from: startingPosition,
durationMs: this.animationDurationMs,
});
});
diff.added.forEach(({ piece, square }) => {
this._getBoardSquare(square).setPiece(piece, this._pieceMoveable(piece), {
type: "fade-in",
durationMs: this.animationDurationMs,
});
});
// Default tabbable square might change with position change
this._refreshDefaultTabbableSquare();
}
}
/**
* Square that is considered "tabbable", if any. Keyboard navigation
* on the board uses a roving tabindex, which means that only one square is
* "tabbable" at a time (the rest are navigable using up and down keys on
* the keyboard).
*/
get tabbableSquare() {
return this._tabbableSquare || this._defaultTabbableSquare;
}
set tabbableSquare(value) {
if (this.tabbableSquare !== value) {
// Unset previous tabbable square so that tabindex is changed to -1
this._getBoardSquare(this.tabbableSquare).tabbable = false;
this._getBoardSquare(value).tabbable = true;
this._tabbableSquare = value;
}
}
/**
* Start a move on the board at `square`, optionally with specified targets
* at `targetSquares`.
*/
startMove(square, targetSquares) {
if (this._interactable(square)) {
this._setBoardState({
id: "awaiting-second-touch",
startSquare: square,
});
this._startInteraction(square, targetSquares);
}
}
/**
* Cancels in-progress moves, if any.
*/
cancelMove() {
this._cancelMove(false);
}
get _focusedSquare() {
return Board._extractSquareData(this._shadowRef.activeElement);
}
_startInteraction(square, forceTargetSquares) {
const piece = this._position[square];
if (piece) {
let targetsLimited = false;
const targetSquares = [];
if (forceTargetSquares !== undefined) {
targetsLimited = true;
forceTargetSquares.forEach((s) => {
if (keyIsSquare(s)) {
targetSquares.push(s);
}
});
}
else {
this._dispatchEvent(new CustomEvent("movestart", {
bubbles: true,
detail: {
from: square,
piece,
setTargets: (squares) => {
targetsLimited = true;
for (const s of squares) {
if (keyIsSquare(s)) {
targetSquares.push(s);
}
}
},
},
}));
}
this._getBoardSquare(square).startInteraction();
this.tabbableSquare = square;
this._boardSquares.forEach((s) => {
if (s.label !== square) {
s.moveTarget = !targetsLimited || targetSquares.includes(s.label);
s.markedTarget = targetsLimited && s.moveTarget;
}
});
}
}
_finishMove(to, animate) {
if (this._boardState.startSquare) {
const from = this._boardState.startSquare;
const piece = this._position[from];
if (piece !== undefined) {
const endEvent = new CustomEvent("moveend", {
bubbles: true,
cancelable: true,
detail: { from, to, piece },
});
this._dispatchEvent(endEvent);
if (endEvent.defaultPrevented) {
return false;
}
const startingPosition = this._getStartingPositionForMove(from, to);
this._getBoardSquare(from).clearPiece();
this._getBoardSquare(to).setPiece(piece, this._pieceMoveable(piece),
// Animate transition only when piece is displaced to a specific location
animate
? {
type: "slide-in",
from: startingPosition,
durationMs: this.animationDurationMs,
}
: undefined);
// Tabbable square always updates to target square
this.tabbableSquare = to;
this._position[to] = this._position[from];
delete this._position[from];
const finishedEvent = new CustomEvent("movefinished", {
bubbles: true,
detail: { from, to, piece },
});
if (animate) {
this._getBoardSquare(to).boardPiece?.animationFinished?.then(() => {
this._dispatchEvent(finishedEvent);
});
}
else {
this._dispatchEvent(finishedEvent);
}
}
this._resetBoardStateAndMoves();
return true;
}
return false;
}
_userCancelMove(animate) {
if (this._boardState.startSquare) {
const e = new CustomEvent("movecancel", {
bubbles: true,
cancelable: true,
detail: {
from: this._boardState.startSquare,
piece: this._position[this._boardState.startSquare],
},
});
this._dispatchEvent(e);
if (!e.defaultPrevented) {
this._cancelMove(animate);
return true;
}
}
return false;
}
_cancelMove(animate) {
if (this._boardState.startSquare) {
const square = this._getBoardSquare(this._boardState.startSquare);
square.cancelInteraction(animate ? this.animationDurationMs : undefined);
}
this._resetBoardStateAndMoves();
}
_focusTabbableSquare() {
if (this.tabbableSquare) {
this._getBoardSquare(this.tabbableSquare).focus();
}
}
_blurTabbableSquare() {
if (this.tabbableSquare) {
this._getBoardSquare(this.tabbableSquare).blur();
}
}
_resetBoardStateAndMoves() {
this._boardSquares.forEach((s) => {
s.removeMoveState();
s.markedTarget = false;
});
this._setBoardState({
id: this.interactive ? "awaiting-input" : "default",
});
}
_pieceMoveable(piece) {
return !this.turn || piece.color === this.turn;
}
_interactable(square) {
const piece = this._position[square];
return !!piece && this._pieceMoveable(piece);
}
_isValidMove(from, to) {
return from !== to && this._getBoardSquare(to).moveTarget;
}
_getBoardSquare(square) {
return this._boardSquares[getVisualIndex(square, this.orientation)];
}
/**
* Compute an explicit position to apply to a piece that is being moved
* from `from` to `to`. This can either be the explicit piece position,
* if already set, for that piece, or it is computed as the offset or
* difference in rows and columns between the two squares.
*/
_getStartingPositionForMove(from, to) {
const [fromRow, fromCol] = getVisualRowColumn(from, this.orientation);
const [toRow, toCol] = getVisualRowColumn(to, this.orientation);
return (this._getBoardSquare(from).explicitPiecePosition || {
type: "squareOffset",
deltaRows: fromRow - toRow,
deltaCols: fromCol - toCol,
});
}
/**
* When no tabbable square has been explicitly set (usually, when user has
* not yet tabbed into or interacted with the board, we want to calculate
* the tabbable square dynamically. It is either:
* - the first occupied square from the player's orientation (i.e. from
* bottom left of board), or
* - the bottom left square of the board.
*/
_refreshDefaultTabbableSquare() {
const oldDefaultSquare = this._defaultTabbableSquare;
let pieceFound = false;
if (Object.keys(this._position).length > 0) {
for (let row = 7; row >= 0 && !pieceFound; row--) {
for (let col = 0; col <= 7 && !pieceFound; col++) {
const square = getSquare(8 * row + col, this.orientation);
if (this._position[square]) {
this._defaultTabbableSquare = square;
pieceFound = true;
}
}
}
}
if (!pieceFound) {
this._defaultTabbableSquare = getSquare(56, this.orientation);
}
// If tabbable square is set to default and has changed, then
// update the two squares accordingly.
if (this._tabbableSquare === undefined &&
oldDefaultSquare !== this._defaultTabbableSquare) {
this._getBoardSquare(oldDefaultSquare).tabbable = false;
this._getBoardSquare(this._defaultTabbableSquare).tabbable = true;
}
}
_setBoardState(state) {
const oldState = this._boardState;
this._boardState = state;
if (this._boardState.id !== oldState.id) {
this._table.classList.toggle("dragging", this._isDragState());
}
if (this._boardState.highlightedSquare !== oldState.highlightedSquare) {
if (oldState.highlightedSquare) {
this._getBoardSquare(oldState.highlightedSquare).hover = false;
}
if (this._boardState.highlightedSquare) {
this._getBoardSquare(this._boardState.highlightedSquare).hover = true;
}
}
}
_handlePointerDown(square, e) {
// We will control focus entirely ourselves
e.preventDefault();
// Primary clicks only
if (e.button !== 0) {
return;
}
switch (this._boardState.id) {
case "awaiting-input":
if (square && this._interactable(square)) {
this._setBoardState({
id: "touching-first-square",
startSquare: square,
touchStartX: e.clientX,
touchStartY: e.clientY,
});
this._startInteraction(square);
this._getBoardSquare(square).toggleSecondaryPiece(true);
}
break;
case "awaiting-second-touch":
case "moving-piece-kb":
if (this._boardState.startSquare === square) {
// Second pointerdown on the same square *may* be a cancel, but could
// also be a misclick/readjustment in order to begin dragging. Wait
// till corresponding pointerup event in order to cancel.
this._setBoardState({
id: "canceling-second-touch",
startSquare: square,
touchStartX: e.clientX,
touchStartY: e.clientY,
});
// Show secondary piece while pointer is down
this._getBoardSquare(square).toggleSecondaryPiece(true);
}
else if (square) {
this._setBoardState({
id: "touching-second-square",
startSquare: this._boardState.startSquare,
});
}
break;
case "dragging":
case "dragging-outside":
case "canceling-second-touch":
case "touching-first-square":
case "touching-second-square":
// Noop: pointer is already down while dragging or touching square
break;
case "default":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
}
_handlePointerUp(square) {
let newFocusedSquare = square;
switch (this._boardState.id) {
case "touching-first-square":
this._getBoardSquare(this._boardState.startSquare).toggleSecondaryPiece(false);
this._setBoardState({
id: "awaiting-second-touch",
startSquare: this._boardState.startSquare,
});
newFocusedSquare = this._boardState.startSquare;
break;
case "canceling-second-touch":
// User cancels by clicking on the same square.
if (!this._userCancelMove(false)) {
this._setBoardState({
id: "awaiting-second-touch",
startSquare: this._boardState.startSquare,
});
}
newFocusedSquare = this._boardState.startSquare;
break;
case "dragging":
case "dragging-outside":
case "touching-second-square":
{
this._getBoardSquare(this._boardState.startSquare).toggleSecondaryPiece(false);
let done = false;
if (square &&
this._isValidMove(this._boardState.startSquare, square)) {
done = this._finishMove(square, !this._isDragState());
if (!done) {
newFocusedSquare = this._boardState.startSquare;
}
}
else {
newFocusedSquare = this._boardState.startSquare;
done = this._userCancelMove(square !== this._boardState.startSquare);
}
if (!done) {
this._setBoardState({
id: "awaiting-second-touch",
startSquare: this._boardState.startSquare,
});
this._getBoardSquare(this._boardState.startSquare).resetPiecePosition(this.animationDurationMs);
}
}
break;
case "awaiting-input":
case "moving-piece-kb":
case "awaiting-second-touch":
// noop: Either we are in a non-mouse state or we are delegating to click
break;
case "default":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
// If board currently has focus, move focus to newly clicked square.
if (this._focusedSquare && newFocusedSquare) {
this.tabbableSquare = newFocusedSquare;
this._focusTabbableSquare();
}
// Prevent click handling for a certain duration
this._preventClickHandling = true;
setTimeout(() => {
this._preventClickHandling = false;
}, Board.POINTERUP_CLICK_PREVENT_DURATION_MS);
}
_handlePointerMove(square, e) {
switch (this._boardState.id) {
case "canceling-second-touch":
case "touching-first-square":
{
const delta = Math.sqrt((e.clientX - this._boardState.touchStartX) ** 2 +
(e.clientY - this._boardState.touchStartY) ** 2);
const squareWidth = this._getBoardSquare(this._boardState.startSquare).width;
const threshold = Math.max(Board.DRAG_THRESHOLD_MIN_PIXELS, Board.DRAG_THRESHOLD_SQUARE_WIDTH_FRACTION * squareWidth);
// Consider a "dragging" action to be when we have moved the pointer a sufficient
// threshold, or we are now in a different square from where we started.
if (delta > threshold || square !== this._boardState.startSquare) {
this._getBoardSquare(this._boardState.startSquare).displacePiece(e.clientX, e.clientY);
if (square) {
this._setBoardState({
id: "dragging",
startSquare: this._boardState.startSquare,
highlightedSquare: this._isValidMove(this._boardState.startSquare, square)
? square
: undefined,
});
}
else {
this._setBoardState({
id: "dragging-outside",
startSquare: this._boardState.startSquare,
});
}
}
}
break;
case "dragging":
case "dragging-outside":
this._getBoardSquare(this._boardState.startSquare).displacePiece(e.clientX, e.clientY);
if (square && square !== this._boardState.highlightedSquare) {
this._setBoardState({
id: "dragging",
startSquare: this._boardState.startSquare,
highlightedSquare: this._isValidMove(this._boardState.startSquare, square)
? square
: undefined,
});
}
else if (!square && this._boardState.id !== "dragging-outside") {
this._setBoardState({
id: "dragging-outside",
startSquare: this._boardState.startSquare,
});
}
break;
case "awaiting-input":
case "awaiting-second-touch":
case "default":
case "moving-piece-kb":
case "touching-second-square":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
}
_handleClick(square) {
if (this._preventClickHandling) {
return;
}
switch (this._boardState.id) {
case "awaiting-input":
if (square && this._interactable(square)) {
this._setBoardState({
id: "awaiting-second-touch",
startSquare: square,
});
this._startInteraction(square);
}
break;
case "awaiting-second-touch":
case "moving-piece-kb":
{
const done = square && this._isValidMove(this._boardState.startSquare, square)
? this._finishMove(square, true)
: this._userCancelMove(square !== this._boardState.startSquare);
if (!done) {
this._setBoardState({
id: "awaiting-second-touch",
startSquare: this._boardState.startSquare,
});
this._getBoardSquare(this._boardState.startSquare).resetPiecePosition(this.animationDurationMs);
}
}
break;
case "touching-first-square":
case "touching-second-square":
case "canceling-second-touch":
case "dragging":
case "dragging-outside":
case "default":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
// If board currently has focus, move focus to newly clicked square.
if (this._focusedSquare && square) {
this.tabbableSquare = square;
this._focusTabbableSquare();
}
}
_handleFocusIn(square) {
if (square) {
if (
// Some browsers (Safari) focus on board squares that are not tabbable
// (tabindex = -1). If that happens, update tabbable square manually.
square !== this.tabbableSquare ||
// Assign tabbable square if none is explicitly assigned yet.
this._tabbableSquare === undefined) {
this.tabbableSquare = square;
}
}
}
_handleKeyDown(square, e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
switch (this._boardState.id) {
case "awaiting-input":
if (square && this._interactable(square)) {
this._setBoardState({
id: "moving-piece-kb",
startSquare: square,
highlightedSquare: undefined,
});
this._startInteraction(square);
}
break;
case "moving-piece-kb":
case "awaiting-second-touch":
// Only move if enter was inside squares area and if start
// and end square are not the same.
if (square &&
this._isValidMove(this._boardState.startSquare, square)) {
this._finishMove(square, true);
}
else {
this._userCancelMove(false);
}
break;
case "dragging":
case "dragging-outside":
case "touching-first-square":
case "touching-second-square":
case "canceling-second-touch":
// Noop: don't handle keypresses in active pointer states
break;
case "default":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
}
else {
const currentIdx = getVisualIndex(this.tabbableSquare, this.orientation);
const currentRow = currentIdx >> 3;
const currentCol = currentIdx & 0x7;
let newIdx = currentIdx;
let keyHandled = false;
switch (e.key) {
case "ArrowRight":
case "Right":
newIdx = 8 * currentRow + Math.min(7, currentCol + 1);
keyHandled = true;
break;
case "ArrowLeft":
case "Left":
newIdx = 8 * currentRow + Math.max(0, currentCol - 1);
keyHandled = true;
break;
case "ArrowDown":
case "Down":
newIdx = 8 * Math.min(7, currentRow + 1) + currentCol;
keyHandled = true;
break;
case "ArrowUp":
case "Up":
newIdx = 8 * Math.max(0, currentRow - 1) + currentCol;
keyHandled = true;
break;
case "Home":
newIdx = e.ctrlKey ? 0 : 8 * currentRow;
keyHandled = true;
break;
case "End":
newIdx = e.ctrlKey ? 63 : 8 * currentRow + 7;
keyHandled = true;
break;
case "PageUp":
newIdx = currentCol;
keyHandled = true;
break;
case "PageDown":
newIdx = 56 + currentCol;
keyHandled = true;
break;
}
if (keyHandled) {
// Prevent native browser scrolling via any of the
// navigation keys since the focus below will auto-scroll
e.preventDefault();
}
if (newIdx !== currentIdx) {
this.tabbableSquare = getSquare(newIdx, this.orientation);
this._focusTabbableSquare();
// If we are currently in a non-keyboard friendly state, we should
// still transition to one since we started keyboard navigation.
switch (this._boardState.id) {
case "moving-piece-kb":
case "awaiting-second-touch":
this._setBoardState({
id: "moving-piece-kb",
startSquare: this._boardState.startSquare,
highlightedSquare: this._boardState.startSquare !== this.tabbableSquare
? this._tabbableSquare
: undefined,
});
break;
case "awaiting-input":
case "touching-first-square":
case "touching-second-square":
case "canceling-second-touch":
case "dragging":
case "dragging-outside":
break;
case "default":
break;
// istanbul ignore next
default:
assertUnreachable(this._boardState);
}
}
}
}
/**
* Convenience wrapper to make pointer, blur, or keyboard event handler for
* square elements. Attempts to extract square label from the element in
* question, then passes square label and current event to `callback`.
*/
_makeEventHandler(callback) {
const boundCallback = callback.bind(this);
return (e) => {
// For mouse events, use client X and Y location to find target reliably.
const square = Board._isMouseEvent(e)
? this._shadowRef
.elementsFromPoint(e.clientX, e.clientY)
.map((e) => Board._extractSquareData(e))
.find((e) => !!e)
: Board._extractSquareData(e.target);
boundCallback(square, e);
};
}
_isDragState() {
return ["dragging", "dragging-outside"].includes(this._boardState.id);
}
static _extractSquareData(target) {
if (!!target && !!target.dataset) {
const dataset = target.dataset;
return keyIsSquare(dataset.square) ? dataset.square : undefined;
}
return undefined;
}
static _isMouseEvent(e) {
return e.clientX !== undefined;
}
static _isSlotElement(e) {
return !!e && e.assignedElements !== undefined;
}
}
/**
* Fraction of square width that pointer must be moved to be
* considered a "drag" action.
*/
Object.defineProperty(Board, "DRAG_THRESHOLD_SQUARE_WIDTH_FRACTION", {
enumerable: true,
configurable: true,
writable: true,
value: 0.1
});
/**
* Minimum number of pixels to enable dragging.
*/
Object.defineProperty(Board, "DRAG_THRESHOLD_MIN_PIXELS", {
enumerable: true,
configurable: true,
writable: true,
value: 2
});
/**
* Amount of time (in ms) to suppress click handling after a pointerup event.
*/
Object.defineProperty(Board, "POINTERUP_CLICK_PREVENT_DURATION_MS", {
enumerable: true,
configurable: true,
writable: true,
value: 250
});
var css_248z = ":host{--square-color-dark:#4c946a;--square-color-light:#e0ddcc;--square-color-dark-hover:#1cc45f;--square-color-light-hover:#fde968;--square-color-dark-active:#19c257;--square-color-light-active:#fadd4c;--outline-color-dark-active:rgba(33,237,94,.95);--outline-color-light-active:hsla(66,97%,72%,.95);--outline-color-focus:rgba(248,140,32,.9);--outline-blur-radius:3px;--outline-spread-radius:4px;--coords-font-size:0.7rem;--coords-font-family:sans-serif;--outer-gutter-width:4%;--inner-border-width:1px;--coords-inside-coord-padding-left:0.5%;--coords-inside-coord-padding-right:0.5%;--move-target-marker-color-dark-square:rgba(8,38,20,.9);--move-target-marker-color-light-square:rgba(8,38,20,.9);--move-target-marker-radius:24%;--move-target-marker-radius-occupied:82%;--ghost-piece-opacity:0.35;--piece-drag-z-index:9999;--piece-drag-coarse-scale:2.4;--piece-padding:3%;--arrow-color-primary:rgba(255,170,0,.8);--arrow-color-secondary:rgba(248,85,63,.8);display:block}:host([hidden]){display:none}.board{border:var(--inner-border-width) solid var(--inner-border-color,var(--square-color-dark));border-collapse:collapse;box-sizing:border-box;table-layout:fixed;touch-action:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.board>tr>td{padding:12.5% 0 0;position:relative}[data-square]{background-color:var(--p-square-color);bottom:0;color:var(--p-label-color);font-family:var(--coords-font-family);font-size:var(--coords-font-size);height:100%;left:0;position:absolute;right:0;top:0;width:100%}[data-square]:focus{box-shadow:inset 0 0 var(--outline-blur-radius) var(--outline-spread-radius) var(--outline-color-focus);outline:none}[data-square].marked-target{background:radial-gradient(var(--p-move-target-marker-color) var(--move-target-marker-radius),var(--p-square-color) calc(var(--move-target-marker-radius) + 1px))}[data-square].has-content.marked-target,[data-square].has-piece.marked-target{background:radial-gradient(var(--p-square-color) var(--move-target-marker-radius-occupied),var(--p-move-target-marker-color) calc(var(--move-target-marker-radius-occupied) + 1px))}[data-square].move-start{--p-square-color:var(--p-square-color-active)}[data-square].move-start:not(:focus){box-shadow:inset 0 0 var(--outline-blur-radius) var(--outline-spread-radius) var(--p-outline-color-active)}@media (hover:hover){[data-square]:is(.moveable,.move-target):hover{--p-square-color:var(--p-square-color-hover)}}[data-square].hover{--p-square-color:var(--p-square-color-hover)}table:not(.dragging) [data-square]:is(.moveable,.move-start,.move-target){cursor:pointer}table.dragging{cursor:grab}.wrapper{position:relative}.coords{display:none;font-family:var(--coords-font-family);font-size:var(--coords-font-size);pointer-events:none;position:absolute;touch-action:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.coord{box-sizing:border-box;display:flex}.coords.file>.coord{width:12.5%}.coords.rank{flex-direction:column}.coords.rank>.coord{height:12.5%}.wrapper.outside{background-color:var(--square-color-light);padding:var(--outer-gutter-width)}.wrapper.outside>.coords{color:var(--square-color-dark);display:flex}.wrapper.outside>.coords>.coord{align-items:center;justify-content:center}.wrapper.outside>.coords.file{bottom:0;height:var(--outer-gutter-width);left:var(--outer-gutter-width);right:var(--outer-gutter-width);width:calc(100% - var(--outer-gutter-width)*2)}.wrapper.outside>.coords.rank{bottom:var(--outer-gutter-width);height:calc(100% - var(--outer-gutter-width)*2);left:0;top:var(--outer-gutter-width);width:var(--outer-gutter-width)}.wrapper.inside>.coords{bottom:0;display:flex;height:100%;left:0;right:0;top:0;width:100%}.wrapper.inside>.coords>.coord.light{color:var(--square-color-dark)}.wrapper.inside>.coords>.coord.dark{color:var(--square-color-light)}.wrapper.inside>.coords.file>.coord{align-items:flex-end;justify-content:flex-end;padding-right:var(--coords-inside-coord-padding-right)}.wrapper.inside>.coords.rank>.coord{padding-left:var(--coords-inside-coord-padding-left)}[data-square-color=dark
const COORDINATES_PLACEMENTS = ["inside", "outside", "hidden"];
class Coordinates {
constructor(props) {
Object.defineProperty(this, "element", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_coordElements", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_orientation", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_direction", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.element = makeHTMLElement("div", {
attributes: {
role: "presentation",
"aria-hidden": "true",
},
classes: ["coords", props.direction],
});
this._direction = props.direction;
this._orientation = props.orientation;
this._coordElements = new Array(8);
const evenSquareColor = props.direction === "file" ? "dark" : "light";
const oddSquareColor = props.direction === "file" ? "light" : "dark";
for (let i = 0; i < 8; i++) {
const color = i % 2 === 0 ? evenSquareColor : oddSquareColor;
const textElement = makeHTMLElement("div", { classes: ["coord", color] });
this._coordElements[i] = textElement;
this.element.appendChild(textElement);
}
this._updateCoordsText();
}
/**
* Orientation of the board; this determines labels for ranks and files.
*/
get orientation() {
return this._orientation;
}
set orientation(value) {
this._orientation = value;
this._updateCoordsText();
}
_updateCoordsText() {
for (let i = 0; i < 8; i++) {
if (this._direction === "file") {
this._coordElements[i].textContent = String.fromCharCode("a".charCodeAt(0) + (this.orientation === "white" ? i : 7 - i));
}
else {
this._coordElements[i].textContent = `${this.orientation === "white" ? 8 - i : i + 1}`;
}
}
}
}
/**
* Type guard for string values that need to conform to a
* `CoordinatesPlacement` definition.
*/
function isCoordinatesPlacement(value) {
return COORDINATES_PLACEMENTS.includes(value);
}
class Arrows {
constructor(orientation) {
Object.defineProperty(this, "element", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_defs", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_group", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_orientation", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_arrows", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_arrowElements", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "_markerElements", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
this.element = makeSVGElement("svg", {
attributes: {
viewBox: "0 0 80 80",
},
classes: ["arrows"],
});
this._orientation = orientation;
this._defs = makeSVGElement("defs");
this.element.appendChild(this._defs);
this._group = makeSVGElement("g");
this.element.appendChild(this._group);
}
get arrows() {
return this._arrows;
}
set arrows(arrows) {
const validArrows = arrows?.filter((a) => a.from !== a.to);
// Update brushes
const brushes = validArrows
? new Set(validArrows.map((a) => Arrows._escapedBrushName(a.brush)))
: new Set();
const oldBrushes = new Set(this._markerElements.keys());
oldBrushes.forEach((key) => {
if (!brushes.has(key)) {
const marker = this._markerElements.get(key);
if (marker) {
this._defs.removeChild(marker);
this._markerElements.delete(key);
}
}
});
brushes.forEach((key) => {
if (!oldBrushes.has(key)) {
const marker = Arrows._makeMarker(key);
this._defs.appendChild(marker);
this._markerElements.set(key, marker);
}
});
// Update arrows
const oldHashes = new Set(this._arrowElements.keys());
const newHashes = validArrows
? new Set(validArrows.map((arrow) => Arrows._arrowHash(arrow)))
: new Set();
oldHashes.forEach((hash) => {
if (!newHashes.has(hash)) {
const element = this._arrowElements.get(hash);
if (element) {
this._group.removeChild(element);
this._arrowElements.delete(hash);
}
}
});
validArrows?.forEach((arrow) => {
const hash = Arrows._arrowHash(arrow);
if (!this._arrowElements.has(hash)) {
const element = this._makeArrow(arrow);
this._arrowElements.set(hash, element);
this._group.appendChild(element);
}
});
this._arrows = validArrows ? [...validArrows] : undefined;
}
/**
* Orientation of the board; this determines direction to draw arrows.
*/
get orientation() {
return this._orientation;
}
set orientation(value) {
if (value !== this._orientation) {
this._orientation = value;
this._arrows?.forEach((arrow) => {
const hash = Arrows._arrowHash(arrow);
const element = this._arrowElements.get(hash);
if (element) {
this._group.removeChild(element);
}
const newElement = this._makeArrow(arrow);
this._group.appendChild(newElement);
this._arrowElements.set(hash, newElement);
});
}
}
_makeArrow(arrow) {
const strokeWidth = Arrows._getSvgStrokeWidth(arrow.weight || Arrows._DEFAULT_ARROW_WEIGHT);
const fromRowCol = getVisualRowColumn(arrow.from, this.orientation);
const toRowCol = getVisualRowColumn(arrow.to, this.orientation);
const coords = {
x1: fromRowCol[1] * 10 + 5,
y1: fromRowCol[0] * 10 + 5,
x2: toRowCol[1] * 10 + 5,
y2: toRowCol[0] * 10 + 5,
};
const endOffset = Arrows._computeXYProjections(strokeWidth * Arrows._ARROW_LENGTH, coords);
const startOffset = Arrows._computeXYProjections(Arrows._ARROW_START_MARGIN, coords);
const escapedBrushName = Arrows._escapedBrushName(arrow.brush || Arrows._DEFAULT_BRUSH_NAME);
const className = Arrows._makeArrowClass(escapedBrushName);
const line = makeSVGElement("line", {
attributes: {
x1: `${coords.x1 + startOffset.x}`,
y1: `${coords.y1 + startOffset.y}`,
x2: `${coords.x2 - endOffset.x}`,
y2: `${coords.y2 - endOffset.y}`,
stroke: "currentColor",
"stroke-width": `${strokeWidth}`,
"marker-end": `url(#${Arrows._makeArrowHeadId(escapedBrushName)})`,
part: className,
},
classes: [className],
});
return line;
}
static _makeMarker(escapedBrushName) {
const marker = makeSVGElement("marker", {
attributes: {
id: Arrows._makeArrowHeadId(escapedBrushName),
refX: "0",
refY: `${Arrows._ARROW_WIDTH / 2}`,
orient: "auto",
markerWidth: `${Arrows._ARROW_LENGTH}`,
markerHeight: `${Arrows._ARROW_WIDTH}`,
},
});
const className = Arrows._makeArrowClass(escapedBrushName);
const polygon = makeSVGElement("polygon", {
attributes: {
fill: "currentColor",
points: `0,0 ${Arrows._ARROW_LENGTH},${Arrows._ARROW_WIDTH / 2} 0,${Arrows._ARROW_WIDTH}`,
part: className,
},
classes: [className],
});
marker.appendChild(polygon);
return marker;
}
static _getSvgStrokeWidth(weight) {
switch (weight) {
case "bold":
return 2.5;
case "light":
return 1;
case "normal":
default:
return 1.8;
}
}
static _escapedBrushName(brush) {
return CSS.escape(brush || Arrows._DEFAULT_BRUSH_NAME);
}
static _makeArrowHeadId(escapedBrushName) {
return `arrowhead-${escapedBrushName}`;
}
static _makeArrowClass(escapedBrushName) {
return `arrow-${escapedBrushName}`;
}
static _computeXYProjections(length, arrow) {
const angle = Math.atan2(arrow.y2 - arrow.y1, arrow.x2 - arrow.x1);
return { x: length * Math.cos(angle), y: length * Math.sin(angle) };
}
static _arrowHash(arrow) {
return `${arrow.from}_${arrow.to}_${arrow.brush || Arrows._DEFAULT_BRUSH_NAME}_${arrow.weight || Arrows._DEFAULT_ARROW_WEIGHT}`;
}
}
/**
* Length of arrow from base to tip, in terms of line "stroke width" units.
*/
Object.defineProperty(Arrows, "_ARROW_LENGTH", {
enumerable: true,
configurable: true,
writable: true,
value: 2.4
});
/**
* Width of arrow base, in terms of line "stroke width" units.
*/
Object.defineProperty(Arrows, "_ARROW_WIDTH", {
enumerable: true,
configurable: true,
writable: true,
value: 2
});
/**
* Margin applied at start of line, along direction of arrow. In CSS viewport units.
*/
Object.defineProperty(Arrows, "_ARROW_START_MARGIN", {
enumerable: true,
configurable: true,
writable: true,
value: 2.7
});
/**
* Default brush name when none is specified for an arrow.
*/
Object.defineProperty(Arrows, "_DEFAULT_BRUSH_NAME", {
enumerable: true,
configurable: true,
writable: true,
value: "primary"
});
/**
* Default arrow weight when none is specified.
*/
Object.defineProperty(Arrows, "_DEFAULT_ARROW_WEIGHT", {
enumerable: true,
configurable: true,
writable: true,
value: "normal"
});
/**
* A component that displays a chess board, with optional interactivity. Allows
* click, drag and keyboard-based moves.
*
* @fires movestart - Fired when the user initiates a move by clicking, dragging or
* via the keyboard.
*
* The event has a `detail` object with the `from` and
* `piece` values for the move. It also has a function, `setTargets(squares)`,
* that the caller can invoke with an array of square labels. This limits the
* set of targets that the piece can be moved to. Note that calling this
* function with an empty list will still allow the piece to be dragged around,
* but no square will accept the piece and thus it will always return to the
* starting square.
*
* @fires moveend - Fired when user is completing a move. This move can be prevented
* from completing by calling `preventDefault()` on the event. If that is called,
* the move itself remains in progress. The event has a `detail` object with `from`
* and `to` set to the square labels of the move, and `piece` containing information
* about the piece that was moved.
*
* @fires movefinished - Fired after a move is completed _and_ animations are resolved.
* The event has a `detail` object with `from` and `to` set to the square labels
* of the move, and `piece` containing information about the piece that was moved.
*
* The `movefinished` event is the best time to update board position in response to
* a move. For example, after a king is moved for castling, the rook can be subsequently
* moved by updating the board position in `movefinished` by setting the `position`
* property.
*
* @fires movecancel - Fired as a move is being canceled by the user. The event
* is *itself* cancelable, ie. a caller can call `preventDefault()` on the event
* to prevent the move from being canceled. Any pieces being dragged will be returned
* to the start square, but the move will remain in progress.
*
* The event has a `detail` object with `from` set to the square label where
* the move was started, and `piece` containing information about the piece that was
* moved.
*
* @cssprop [--square-color-dark=hsl(145deg 32% 44%)] - Color for dark squares.
* @cssprop [--square-color-light=hsl(51deg 24% 84%)] - Color for light squares.
*
* @cssprop [--square-color-dark-hover=hsl(144deg 75% 44%)] - Hover color
* for a dark square. Applied when mouse is hovering over an interactable square
* or a square has keyboard focus during a move.
* @cssprop [--square-color-light-hover=hsl(52deg 98% 70%)] - Hover color
* for a dark square. Applied when mouse is hovering over an interactable square
* or a square has keyboard focus during a move.
*
* @cssprop [--square-color-dark-active=hsl(142deg 77% 43%)] - Color applied to
* dark square when it is involved in (the starting point) of a move. By default
* this color is similar to, but slightly different from, `--square-color-dark-hover`.
* @cssprop [--square-color-light-active=hsl(50deg 95% 64%)] - Color applied to
* light square when it is involved in (the starting point) of a move. By default
* this color is similar to, but slightly different from, `--square-color-light-hover`.
*
* @cssprop [--outline-color-dark-active=hsl(138deg 85% 53% / 95%)] - Color of
* outline applied to dark square when it is the starting point of a move.
* It is applied in addition to `--square-color-dark-active`, and is visible
* when the square does not have focus.
* @cssprop [--outline-color-light-active=hsl(66deg 97% 72% / 95%)] - Color of
* outline applied to light square when it is the starting point of a move.
* It is applied in addition to `--square-color-light-active`, and is visible
* when the square does not have focus.
* @cssprop [--outline-color-focus=hsl(30deg 94% 55% / 90%)] - Color of outline applied to square when it has focus.
*
* @cssprop [--outer-gutter-width=4%] - When the `coordinates` property is `outside`,
* this CSS property controls the width of the gutter outside the board where coords are shown.
* @cssprop [--inner-border-width=1px] - Width of the inside border drawn around the board.
* @cssprop [--inner-border-color=var(--square-color-dark)] - Color of the inside border drawn
* around the board.
*
* @cssprop [--move-target-marker-color-dark-square=hsl(144deg 64% 9% / 90%)] -
* Color of marker shown on dark square when it is an eligible move target.
* @cssprop [--move-target-marker-color-light-square=hsl(144deg 64% 9% / 90%)] -
* Color of marker shown on light square when it is an eligible move target.
*
* @cssprop [--move-target-marker-radius=24%] - Radius of marker on a move target
* square.
* @cssprop [--move-target-marker-radius-occupied=82%] - Radius of marker on
* a move target square that is occupied (by a piece or custom content).
*
* @cssprop [--outline-blur-radius=3px] - Blur radius of all outlines applied to square.
* @cssprop [--outline-spread-radius=4px] - Spread radius of all outlines applied to square.
*
* @cssprop [--coords-font-size=0.7rem] - Font size of coord labels shown on board.
* @cssprop [--coords-font-family=sans-serif] - Font family of coord labels shown on board.
* @cssprop [--coords-inside-coord-padding-left=0.5%] - Left padding applied to coordinates
* when shown inside the board. Percentage values are relative to the width of the board.
* @cssprop [--coords-inside-coord-padding-right=0.5%] - Right padding applied to coordinates
* when shown inside the board. Percentage values are relative to the width of the board.
*
* @cssprop [--ghost-piece-opacity=0.35] - Opacity of ghost piece shown while dragging.
* Set to 0 to hide ghost piece altogether.
* @cssprop [--piece-drag-z-index=9999] - Z-index applied to piece while being dragged.
* @cssprop [--piece-drag-coarse-scale=2.4] - Amount to scale up a piece when doing a
* coarse (touch) drag. On mobile devices, pieces will be scaled up in size to
* make them easier to see.
* @cssprop [--piece-padding=3%] - Padding applied to square when piece is placed in it.
*
* @cssprop [--arrow-color-primary=hsl(40deg 100% 50% / 80%)] - Color applied to arrow
* with brush `primary`.
* @cssprop [--arrow-color-secondary=hsl(7deg 93% 61% / 80%)] - Color applied to arrow
* with brush `secondary`.
*
* @slot a1,a2,...,h8 - Slots that allow placement of custom content -- SVGs, text, or
* any other annotation -- on the corresponding square.
*
* @csspart piece-<b|w><b|r|p|n|k|q> - CSS parts for each of the piece classes. The part
* name is of the form `piece-xy`, where `x` corresponds to the color of the piece --
* either `w` for white or `b` for black, and `y` is the piece type -- one of `p` (pawn),
* `r` (rook), `n` (knight), `b` (bishop), `k` (king), `q` (queen). Thus, `piece-wr`
* would be the CSS part corresponding to the white rook.
*
* The CSS parts can be used to set custom CSS for the pieces (such as changing the image
* for a piece by changing the `background-image` property).
*
* @csspart arrow-<brush_name> - CSS parts for any arrow brushes configured using the
* `brush` field on an arrow specification (see the `arrows` property for more details).
*/
class GChessBoardElement extends HTMLElement {
static get observedAttributes() {
return [
"orientation",
"turn",
"interactive",
"fen",
"coordinates",
"animation-duration",
];
}
constructor() {
super();
Object.defineProperty(this, "_shadow", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_style", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_wrapper", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_board", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_fileCoords", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_rankCoords", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_arrows", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this._shadow = this.attachShadow({ mode: "open" });
this._style = document.createElement("style");
this._style.textContent = css_248z;
this._shadow.appendChild(this._style);
this._wrapper = makeHTMLElement("div", {
classes: ["wrapper", GChessBoardElement._DEFAULT_COORDS_PLACEMENT],
});
this._shadow.appendChild(this._wrapper);
this._board = new Board({
orientation: GChessBoardElement._DEFAULT_SIDE,
animationDurationMs: GChessBoardElement._DEFAULT_ANIMATION_DURATION_MS,
}, (e) => this.dispatchEvent(e), this._shadow);
this._wrapper.appendChild(this._board.element);
this._fileCoords = new Coordinates({
direction: "file",
placement: GChessBoardElement._DEFAULT_COORDS_PLACEMENT,
orientation: GChessBoardElement._DEFAULT_SIDE,
});
this._rankCoords = new Coordinates({
direction: "rank",
placement: GChessBoardElement._DEFAULT_COORDS_PLACEMENT,
orientation: GChessBoardElement._DEFAULT_SIDE,
});
this._wrapper.appendChild(this._fileCoords.element);
this._wrapper.appendChild(this._rankCoords.element);
this._arrows = new Arrows(GChessBoardElement._DEFAULT_SIDE);
this._wrapper.appendChild(this._arrows.element);
}
connectedCallback() {
this._board.addGlobalListeners();
}
disconnectedCallback() {
this._board.removeGlobalListeners();
}
attributeChangedCallback(name, _, newValue) {
switch (name) {
case "interactive":
this._board.interactive = this.interactive;
break;
case "coordinates":
this._wrapper.classList.toggle("outside", this.coordinates === "outside");
this._wrapper.classList.toggle("inside", this.coordinates === "inside");
break;
case "orientation":
this._board.orientation = this.orientation;
this._fileCoords.orientation = this.orientation;
this._rankCoords.orientation = this.orientation;
this._arrows.orientation = this.orientation;
break;
case "turn":
this._board.turn = this.turn;
break;
case "fen":
if (newValue !== null) {
this.fen = newValue;
}
else {
this.position = {};
}
break;
case "animation-duration":
this._board.animationDurationMs = this.animationDuration;
break;
default:
assertUnreachable(name);
}
}
/**
* What side's perspective to render squares from (what color appears on
* the bottom as viewed on the screen). Either `"white"` or `"black"`.
*
* @attr [orientation=white]
*/
get orientation() {
return this._parseRestrictedStringAttributeWithDefault("orientation", isSide, GChessBoardElement._DEFAULT_SIDE);
}
set orientation(value) {
this.setAttribute("orientation", value);
}
/**
* What side is allowed to move pieces. Either `"white`, `"black"`, or
* unset. When unset, pieces from either side can be moved around.
*
* @attr
*/
get turn() {
return this._parseRestrictedStringAttribute("turn", isSide);
}
set turn(value) {
if (value) {
this.setAttribute("turn", value);
}
else {
this.removeAttribute("turn");
}
}
/**
* Whether the squares are interactive, i.e. user can interact with squares,
* move pieces etc. By default, this is false; i.e a board is only for displaying
* a position.
*
* @attr [interactive=false]
*/
get interactive() {
return this._hasBooleanAttribute("interactive");
}
set interactive(interactive) {
this._setBooleanAttribute("interactive", interactive);
}
/**
* A map-like object representing the board position, where object keys are square
* labels, and values are `Piece` objects. Note that changes to this property are
* mirrored in the value of the `fen` property of the element, but **not** the
* corresponding attribute. All changes to position are animated, using the duration
* specified by the `animationDuration` property.
*
* Example:
*
* ```js
* board.position = {
* a2: {
* pieceType: "king",
* color: "white"
* },
* g4: {
* pieceType: "knight",
* color: "black"
* },
* };
* ```
*/
get position() {
return this._board.position;
}
set position(value) {
this._board.position = { ...value };
}
/**
* FEN string representing the board position. Note that changes to the corresponding
* `fen` _property_ will **not** reflect onto the "fen" _attribute_ of the element.
* In other words, to get the latest FEN string for the board position, use the `fen`
* _property_.
*
* Accepts the special string `"start"` as shorthand for the starting position
* of a chess game. An empty string represents an empty board. Invalid FEN values
* are ignored with an error.
*
* Note that a FEN string normally contains 6 components, separated by slashes,
* but only the first component (the "piece placement" component) is used by this
* attribute.
*
* @attr
*/
get fen() {
return getFen(this._board.position);
}
set fen(value) {
const position = getPosition(value);
if (position !== undefined) {
this.position = position;
}
else {
throw new Error(`Invalid FEN position: ${value}`);
}
}
/**
* How to display coordinates for squares. Could be `"inside"` the board (default),
* `"outside"`, or `"hidden"`.
*
* @attr [coordinates=inside]
*/
get coordinates() {
return this._parseRestrictedStringAttributeWithDefault("coordinates", isCoordinatesPlacement, GChessBoardElement._DEFAULT_COORDS_PLACEMENT);
}
set coordinates(value) {
this.setAttribute("coordinates", value);
}
/**
* Duration, in milliseconds, of animation when adding/removing/moving pieces.
*
* @attr [animation-duration=200]
*/
get animationDuration() {
return this._parseNumberAttribute("animation-duration", GChessBoardElement._DEFAULT_ANIMATION_DURATION_MS);
}
set animationDuration(value) {
this._setNumberAttribute("animation-duration", value);
}
/**
* Set of arrows to draw on the board. This is an array of objects specifying
* arrow characteristics, with the following properties: (1) `from` and `to`
* corresponding to the start and end squares for the arrow, (2) optional
* `weight` for the line (values: `"light"`, `"normal"`, `"bold"`), and
* (3) `brush`, which is a string that will be used to make a CSS part
* where one can customize the color, opacity, and other styles of the
* arrow. For example, a value for `brush` of `"foo"` will apply a
* CSS part named `arrow-foo` to the arrow.
*
* Note: because the value of `brush` becomes part of a CSS part name, it
* should be usable as a valid CSS identifier.
*
* In addition to allowing arbitrary part names, arrows support a few
* out-of-the-box brush names, `primary` and `secondary`, which colors
* defined with CSS custom properties `--arrow-color-primary` and
* `--arrow-color-secondary`.
*
* Example:
*
* ```js
* board.arrows = [
* { from: "e2", to: "e4" },
* {
* from: "g1",
* to: "f3",
* brush: "foo"
* },
* {
* from: "c7",
* to: "c5",
* brush: "secondary"
* },
* ];
*/
get arrows() {
return this._arrows.arrows;
}
set arrows(arrows) {
this._arrows.arrows = arrows;
}
addEventListener(type, listener, options) {
super.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
super.removeEventListener(type, listener, options);
}
/**
* Start a move on the board at `square`, optionally with specified targets
* at `targetSquares`.
*/
startMove(square, targetSquares) {
this._board.startMove(square, targetSquares);
}
/**
* Imperatively cancel any in-progress moves.
*/
cancelMove() {
this._board.cancelMove();
}
_hasBooleanAttribute(name) {
return (this.hasAttribute(name) &&
this.getAttribute(name)?.toLowerCase() !== "false");
}
_setBooleanAttribute(name, value) {
if (value) {
this.setAttribute(name, "");
}
else {
this.removeAttribute(name);
}
}
_setNumberAttribute(name, value) {
this.setAttribute(name, value.toString());
}
_parseRestrictedStringAttribute(name, guard) {
const value = this.getAttribute(name);
return guard(value) ? value : undefined;
}
_parseRestrictedStringAttributeWithDefault(name, guard, defaultValue) {
const parsed = this._parseRestrictedStringAttribute(name, guard);
return parsed !== undefined ? parsed : defaultValue;
}
_parseNumberAttribute(name, defaultValue) {
const value = this.getAttribute(name);
return value === null || Number.isNaN(Number(value))
? defaultValue
: Number(value);
}
}
Object.defineProperty(GChessBoardElement, "_DEFAULT_SIDE", {
enumerable: true,
configurable: true,
writable: true,
value: "white"
});
Object.defineProperty(GChessBoardElement, "_DEFAULT_ANIMATION_DURATION_MS", {
enumerable: true,
configurable: true,
writable: true,
value: 200
});
Object.defineProperty(GChessBoardElement, "_DEFAULT_COORDS_PLACEMENT", {
enumerable: true,
configurable: true,
writable: true,
value: "inside"
});
customElements.define("g-chess-board", GChessBoardElement);
export { GChessBoardElement };
//# sourceMappingURL=index.es.js.map