diff --git a/webapp/src/main/resources/modals/play-bot.html b/webapp/src/main/resources/modals/play-bot.html
index 97481cdc3..328a602c5 100644
--- a/webapp/src/main/resources/modals/play-bot.html
+++ b/webapp/src/main/resources/modals/play-bot.html
@@ -1,145 +1,139 @@
Play Bot
-
+
+
+
+
+
+ Randomized
+
+
+
+
+
+
+
+ Engine Only
+
+
+
+
+
diff --git a/webapp/src/main/resources/modals/signup.html b/webapp/src/main/resources/modals/signup.html
index 086fbc939..960ad419b 100644
--- a/webapp/src/main/resources/modals/signup.html
+++ b/webapp/src/main/resources/modals/signup.html
@@ -33,6 +33,12 @@ Sign Up
+
+
+
+ Transfer guest data (games and puzzles played as guest, etc.) to this new user
+
+
diff --git a/webapp/src/main/resources/public/css/about-faq.css b/webapp/src/main/resources/public/css/about-faq.css
index e33bbc2dc..600baf48d 100644
--- a/webapp/src/main/resources/public/css/about-faq.css
+++ b/webapp/src/main/resources/public/css/about-faq.css
@@ -55,3 +55,65 @@
word-wrap: break-word;
word-break: break-word;
}
+
+#page-toc {
+ background-color: #f4f4f4;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 20px 10px;
+}
+
+#page-toc ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+#page-toc li {
+ margin: 2px 0;
+ font-size: 15px;
+ line-height: 1.5;
+}
+
+#page-toc a {
+ font-size: inherit;
+}
+
+.anchor-copy-link {
+ cursor: pointer;
+ margin-left: 6px;
+ vertical-align: middle;
+}
+
+.anchor-copy-link img {
+ width: 18px;
+ opacity: 70%;
+ vertical-align: middle;
+ position: relative;
+ top: -4px;
+}
+
+.anchor-copy-link:hover img {
+ opacity: 100%;
+}
+
+@media (max-width: 1000px) {
+ #page-toc li {
+ font-size: 30px;
+ }
+
+ #annotation-symbols-table th:nth-child(1) {
+ width: 170px;
+ }
+
+ #annotation-symbols-table th:nth-child(2) {
+ width: 230px;
+ }
+
+ #annotation-symbols-table th:nth-child(3) {
+ width: 80px;
+ }
+
+ .anchor-copy-link img {
+ width: 36px;
+ }
+}
diff --git a/webapp/src/main/resources/public/css/admin/admin-console.css b/webapp/src/main/resources/public/css/admin/admin-console.css
index 5c242e52b..2b6477ea2 100644
--- a/webapp/src/main/resources/public/css/admin/admin-console.css
+++ b/webapp/src/main/resources/public/css/admin/admin-console.css
@@ -22,7 +22,7 @@ h2 {
}
.stats-table {
- font-size: 15px;
+ font-size: 14px;
}
.stats-table th,
diff --git a/webapp/src/main/resources/public/css/board.css b/webapp/src/main/resources/public/css/board.css
index 08a524a80..3750030e1 100644
--- a/webapp/src/main/resources/public/css/board.css
+++ b/webapp/src/main/resources/public/css/board.css
@@ -114,6 +114,10 @@ a.board-outer-container-link-mask {
z-index: 100;
}
+.board-container.colorblind-friendly-black-pieces .piece-image-black {
+ filter: invert(1);
+}
+
.piece-holder .piece-image-can-move {
cursor: move;
}
diff --git a/webapp/src/main/resources/public/css/common/nice-table.css b/webapp/src/main/resources/public/css/common/nice-table.css
new file mode 100644
index 000000000..36e2fb017
--- /dev/null
+++ b/webapp/src/main/resources/public/css/common/nice-table.css
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+
+/*
+ * Centralized "nice table" styles.
+ *
+ * Usage:
+ *
+ *
+ * Modifiers:
+ * .nice-table-striped : zebra-stripes the body rows
+ * .nice-table-hover : highlights rows on hover
+ */
+
+.nice-table {
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+ border: 1px solid #d0d0d0;
+ background-color: #f5f0eb;
+ border-radius: 10px;
+ margin-top: 10px;
+ margin-bottom: 44px;
+ overflow: hidden;
+}
+
+.nice-table td,
+.nice-table th {
+ display: table-cell;
+ padding: 8px 12px;
+ border-bottom: 1px solid #e0dcd8;
+ border-right: 1px solid #e0dcd8;
+}
+
+.nice-table td:last-child,
+.nice-table th:last-child {
+ border-right: none;
+}
+
+.nice-table tr:last-child td {
+ border-bottom: none;
+}
+
+.nice-table th {
+ background-color: #d5d5d5;
+ font-weight: bold;
+ padding: 10px 12px;
+}
+
+.nice-table-striped tbody tr:nth-child(even),
+.nice-table-striped > tr:nth-child(even) {
+ background-color: #f0ebe6;
+}
+
+.nice-table-hover tbody tr:hover {
+ background-color: #ece9e4;
+}
+
+@media (max-width: 1000px) {
+ .nice-table {
+ max-width: 100%;
+ border-radius: 0;
+ margin-top: 20px;
+ margin-bottom: 60px;
+ }
+
+ .nice-table td,
+ .nice-table th {
+ padding: 14px 10px;
+ font-size: 30px;
+ }
+}
diff --git a/webapp/src/main/resources/public/css/database/database-game-viewer.css b/webapp/src/main/resources/public/css/database/database-game-viewer.css
index 6bfb4f13f..07fa8f0bb 100644
--- a/webapp/src/main/resources/public/css/database/database-game-viewer.css
+++ b/webapp/src/main/resources/public/css/database/database-game-viewer.css
@@ -18,5 +18,21 @@
*/
#game-event-info {
- margin-top: 6px;
+ margin-top: 12px;
+}
+
+/*
+ * Player name links inside #players-info: override the default link color
+ * (and the :visited link color, which has specificity 0,1,1 and would beat
+ * a plain .red-color / .black-color rule) so that the red / black player
+ * names actually appear in their respective colors.
+ */
+#players-info a.red-color,
+#players-info a.red-color:visited {
+ color: rgba(222, 7, 7);
+}
+
+#players-info a.black-color,
+#players-info a.black-color:visited {
+ color: rgba(0, 0, 0);
}
diff --git a/webapp/src/main/resources/public/css/database/database.css b/webapp/src/main/resources/public/css/database/database.css
index d6e98cfe5..d80b9e98a 100644
--- a/webapp/src/main/resources/public/css/database/database.css
+++ b/webapp/src/main/resources/public/css/database/database.css
@@ -17,59 +17,18 @@
* along with this program. If not, see
.
*/
+/*
+ * Database-specific overrides on top of the shared `.nice-table` styles
+ * (see /css/common/nice-table.css).
+ */
+
.database-table {
- border-collapse: separate;
- border-spacing: 0;
table-layout: fixed;
- width: 100%;
max-width: 600px;
- border: 1px solid #d0d0d0;
- background-color: #f5f0eb;
- border-radius: 10px;
- margin-top: 10px;
- margin-bottom: 44px;
- overflow: hidden;
-}
-
-.database-table td,
-.database-table th {
- display: table-cell;
- padding: 8px 12px;
- border-bottom: 1px solid #e0dcd8;
- border-right: 1px solid #e0dcd8;
-}
-
-.database-table td:last-child,
-.database-table th:last-child {
- border-right: none;
-}
-
-.database-table tr:last-child td {
- border-bottom: none;
-}
-
-.database-table th {
- background-color: #d5d5d5;
- font-weight: bold;
- padding: 10px 12px;
-}
-
-.database-table tr:nth-child(even) {
- background-color: #f0ebe6;
}
-
@media (max-width: 1000px) {
.database-table {
max-width: 100%;
- border-radius: 0;
- margin-top: 20px;
- margin-bottom: 60px;
- }
-
- .database-table td,
- .database-table th {
- padding: 14px 10px;
- font-size: 30px;
}
}
diff --git a/webapp/src/main/resources/public/css/how-to-play-xiangqi.css b/webapp/src/main/resources/public/css/how-to-play-xiangqi.css
new file mode 100644
index 000000000..d7c9b4dfd
--- /dev/null
+++ b/webapp/src/main/resources/public/css/how-to-play-xiangqi.css
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+
+.piece-rules {
+ display: flex;
+ flex-flow: column;
+ width: 100%;
+ margin-top: 30px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.piece-row {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ flex-flow: row;
+ margin-bottom: 30px;
+}
+
+.piece-row .explanation {
+ width: 36%;
+ padding-right: 20px;
+}
+
+.piece-row .board {
+ width: 64%;
+}
+
+.board-container-about {
+ width: 570px;
+}
+
+#settings-info-box-container {
+ width: 330px;
+}
+
+@media (max-width: 1000px) {
+ .piece-row {
+ flex-flow: column;
+ }
+
+ .piece-row .explanation,
+ .piece-row .board,
+ .piece-rules {
+ width: 100%;
+ }
+
+ .piece-row,
+ .piece-rules {
+ margin-bottom: 60px;
+ }
+
+ .piece-row .explanation {
+ padding-right: 0;
+ }
+
+ .board-container-about {
+ width: 84%;
+ }
+
+ #settings-info-box-container {
+ width: 660px;
+ }
+}
diff --git a/webapp/src/main/resources/public/css/lobby.css b/webapp/src/main/resources/public/css/lobby.css
index c4ca0e8ad..d648e2994 100644
--- a/webapp/src/main/resources/public/css/lobby.css
+++ b/webapp/src/main/resources/public/css/lobby.css
@@ -50,13 +50,15 @@ h1 {
}
.flex-games-to-join-list-container {
+ flex: 2 1 800px;
min-width: 400px;
- max-width: 850px;
+ max-width: unset;
}
.flex-new-game-logo-container {
+ flex: 1 1 320px;
min-width: 350px;
- max-width: 500px;
+ max-width: unset;
}
#games-to-join-list-container {
@@ -69,12 +71,18 @@ h1 {
align-items: start;
border: 2px solid #323232;
border-radius: 3px;
- box-shadow: rgba(67, 71, 85, 0.27) 0px 0px 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em;
+ box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em;
}
#games-to-join-list-table-container {
width: 100%;
- padding: 12px 14px;
+ padding: 14px 16px;
+}
+
+#games-to-join-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
}
.discrete-messages {
@@ -91,97 +99,148 @@ h1 {
margin-top: 30px;
}
-table#games-to-join-list-table {
- border-collapse: separate;
- border-spacing: 1px;
-}
-
-table#games-to-join-list-table th {
- text-align: left;
- font-weight: normal;
- padding: 0;
- font-size: 14px;
- color: #434343;
+.game-to-join-item {
+ display: flex;
+ align-items: center;
+ background-color: #ece5d8;
+ border: 1px solid #565656;
+ border-radius: 3px;
+ min-height: 64px;
+ padding: 6px 10px;
+ box-shadow: rgba(0, 0, 0, 0.12) 0 1px 3px, rgba(0, 0, 0, 0.2) 0 1px 2px;
}
-/* col spanner exists so bottom line solid pattern isn't broken */
-table#games-to-join-list-table tr.col-spanner,
-table#games-to-join-list-table tr.col-spanner td {
- border-bottom: 1px solid #474747;
- height: 8px;
+.game-to-join-variant-pane,
+.game-to-join-time-control-pane,
+.game-to-join-middle-pane,
+.game-to-join-right-pane,
+.game-to-join-rating-pane {
+ display: flex;
+ align-items: center;
}
-table#games-to-join-list-table tr td {
- border-bottom: 1px dashed #605f5f;
- height: 42px;
+.game-to-join-variant-pane {
+ width: 54px;
+ justify-content: center;
}
-table#games-to-join-list-table tr.last-row td.last-row {
- border-bottom-width: 0;
+.game-to-join-time-control-pane {
+ width: 80px;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
}
-table#games-to-join-list-table td {
- font-size: 16px;
- vertical-align: middle;
+.game-to-join-middle-pane {
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-left: 10px;
}
-table#games-to-join-list-table td.online-cell,
-table#games-to-join-list-table th.online-cell {
- width: 22px;
+.game-to-join-right-pane {
+ justify-content: flex-end;
+ margin-left: 12px;
+ margin-right: 8px;
}
-table#games-to-join-list-table td.online-cell {
- padding-left: 10px;
+.game-to-join-rating-pane {
+ justify-content: center;
+ margin-right: 20px;
}
-table#games-to-join-list-table td.username-cell,
-table#games-to-join-list-table th.username-cell {
- width: 150px;
- max-width: 150px;
+.game-to-join-opponent-line {
+ display: flex;
+ align-items: center;
+ gap: 4px;
}
-table#games-to-join-list-table td.rating-cell,
-table#games-to-join-list-table th.rating-cell {
- width: 82px;
+.game-to-join-opponent-line .username-cell {
+ max-width: 280px;
+ font-size: 15px;
}
-table#games-to-join-list-table td.color-cell,
-table#games-to-join-list-table th.color-cell {
- width: 82px;
+.game-to-join-opponent-line .rating-cell {
+ color: #555;
+ font-size: 14px;
}
-table#games-to-join-list-table td.time-control-icons-cell,
-table#games-to-join-list-table th.time-control-icons-cell {
- width: 34px;
+.game-to-join-metadata-line {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ margin-top: 4px;
+ gap: 6px;
}
-table#games-to-join-list-table td.time-control-cell,
-table#games-to-join-list-table th.time-control-cell {
- width: 130px;
- max-width: 130px;
+.game-to-join-custom-time-line {
+ color: #555;
font-size: 13px;
+ margin-top: 5px;
}
-table#games-to-join-list-table td.rating-mode-cell,
-table#games-to-join-list-table th.rating-mode-cell {
- width: 90px;
+.game-to-join-metadata-item {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ color: #3a3a3a;
+ font-size: 14px;
}
-table#games-to-join-list-table td.separator-cell,
-table#games-to-join-list-table th.separator-cell {
- width: 40px;
+.game-to-join-item .join-button-cell {
+ text-align: right;
}
-table#games-to-join-list-table td.join-button-cell,
-table#games-to-join-list-table th.join-button-cell {
- width: 110px;
+.game-to-join-rating-mode-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ color: #3a3a3a;
+ font-size: 14px;
text-align: center;
}
-input[type=button].join-buttons {
- width: 90px;
- height: 25px;
- font-size: 12px;
+.game-to-join-rating-mode-icon {
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+}
+
+.game-to-join-time-icon-cell {
+ display: flex;
+ justify-content: center;
+}
+
+.game-to-join-item .time-control-icons {
+ width: 22px;
+}
+
+.game-to-join-time-duration-cell {
+ color: #3a3a3a;
+ font-size: 13px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.game-to-join-online-cell {
+ width: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 0;
+}
+
+.game-to-join-online-cell .online-status-indicator {
+ margin-right: 3px;
+}
+
+.join-buttons {
+ height: 34px;
+ font-size: 13px;
+ padding: 0 15px;
}
.no-game-to-join-message {
@@ -236,7 +295,7 @@ img#kofi-logo {
opacity: 82%;
}
-input[type="button"]#create-new-game-button {
+#create-new-game-button {
width: 250px;
height: 45px;
margin-top: 50px;
@@ -308,6 +367,14 @@ iframe {
width: 100%;
}
+ #games-to-join-list-table-container {
+ padding: 0;
+ }
+
+ #games-to-join-list {
+ gap: 0;
+ }
+
.flex-lobby-element {
margin-bottom: 56px;
margin-left: 30px;
@@ -331,11 +398,11 @@ iframe {
margin-top: 44px;
}
- input[type="button"]#create-new-game-button {
- margin-top: 18px;
- margin-bottom: 18px;
+ #create-new-game-button {
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
width: 380px;
- height: 80px;
+ height: 90px;
}
#no-game-to-join-message {
@@ -359,47 +426,85 @@ iframe {
font-size: 26px;
}
- .time-control-icons {
- width: 30px;
- margin-right: 8px;
+ .game-to-join-item .time-control-icons {
+ width: 38px;
+ }
+
+ .game-to-join-variant-pane {
+ width: 90px;
+ }
+
+ .game-to-join-variant-pane .variant-symbol {
+ font-size: 48px;
+ }
+
+ .game-to-join-variant-pane .variant-name {
+ font-size: 20px;
}
#games-to-join-list-container {
- height: 550px;
+ height: 642px;
+ }
+
+ .game-to-join-item {
+ border-radius: 0;
+ min-height: 140px;
+ padding: 10px 12px;
+ }
+
+ .game-to-join-time-control-pane {
+ width: 118px;
+ }
+
+ .game-to-join-middle-pane {
+ margin-left: 10px;
+ }
+
+ .game-to-join-right-pane {
+ margin-left: 12px;
+ margin-right: 8px;
+ }
+
+ .game-to-join-rating-pane {
+ width: 84px;
+ margin-left: 6px;
+ }
+
+ .game-to-join-opponent-line {
+ min-width: 0;
}
- table#games-to-join-list-table th {
+ .game-to-join-opponent-line .username-cell {
+ max-width: unset;
font-size: 24px;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
- table#games-to-join-list-table td {
- font-size: 36px;
+ .game-to-join-opponent-line .rating-cell {
+ flex: 0 0 auto;
+ font-size: 24px;
}
- table#games-to-join-list-table th.time-control-cell,
- table#games-to-join-list-table td.time-control-cell {
- font-size: 30px;
- width: 100px;
- max-width: 100px;
+ .game-to-join-metadata-item {
+ font-size: 28px;
}
- table#games-to-join-list-table td.rating-cell,
- table#games-to-join-list-table th.rating-cell {
- width: 105px;
+ .game-to-join-custom-time-line {
+ font-size: 30px;
}
- table#games-to-join-list-table td.color-cell,
- table#games-to-join-list-table th.color-cell {
- width: 105px;
+ .game-to-join-time-duration-cell {
+ font-size: 28px;
}
- table#games-to-join-list-table td.join-button-cell,
- table#games-to-join-list-table th.join-button-cell {
- width: 90px;
+ .game-to-join-rating-mode-cell {
+ font-size: 30px;
}
- table#games-to-join-list-table tr td {
- height: 76px;
+ .game-to-join-rating-mode-icon {
+ width: 36px;
+ height: 36px;
}
#online-users-and-rating {
@@ -407,16 +512,23 @@ iframe {
font-size: 28px;
}
- input[type=button].join-buttons {
- width: 70px;
- height: 68px;
- font-size: 28px;
+ .join-buttons {
+ min-width: 110px !important;
+ max-width: 120px !important;
+ height: 80px;
+ font-size: 25px;
+ padding: 0 !important;
+ margin: 0;
+ box-sizing: border-box;
}
- table#games-to-join-list-table tr td .online-status-indicator {
+ .game-to-join-item .online-status-indicator {
width: 16px;
height: 16px;
- border-radius: 6px;
+ min-width: 16px;
+ min-height: 16px;
+ flex: 0 0 16px;
+ border-radius: 50%;
margin-right: 6px;
}
diff --git a/webapp/src/main/resources/public/css/my-db-searches.css b/webapp/src/main/resources/public/css/my-db-searches.css
new file mode 100644
index 000000000..b24fe8c3f
--- /dev/null
+++ b/webapp/src/main/resources/public/css/my-db-searches.css
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
.
+ */
+
+.my-game-item {
+ height: 90px;
+}
+
+.my-game-item .default-text {
+ /* Allow long FEN strings to wrap inside the middle pane instead of overflowing. */
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+@media (max-width: 1000px) {
+ /* On mobile the my-game-item from my-user-data-items.css is bumped to 120px
+ and font-size 26px, but the search summary has multi-line content
+ (player, event, date range, FEN) so we need more room and a readable
+ text size for the .default-text summary. */
+ .my-game-item {
+ height: auto;
+ min-height: 140px;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .my-game-item .left-pane {
+ /* Slightly narrower icon column so the summary has more breathing room. */
+ width: 14%;
+ }
+
+ .my-game-item .middle-pane {
+ padding-right: 8px;
+ }
+
+ .my-game-item .default-text {
+ font-size: 22px;
+ line-height: 1.25;
+ }
+}
diff --git a/webapp/src/main/resources/public/css/my-games.css b/webapp/src/main/resources/public/css/my-games.css
index ec5d57452..36206a401 100644
--- a/webapp/src/main/resources/public/css/my-games.css
+++ b/webapp/src/main/resources/public/css/my-games.css
@@ -33,10 +33,12 @@
.my-game-item {
width: 98%;
+ height: 84px;
}
.my-game-item .indicator-pane {
- width: 11%
+ width: 94px;
+ flex: 0 0 94px;
}
.my-game-item .is-ongoing-indicator-container {
diff --git a/webapp/src/main/resources/public/css/my-user-data-items.css b/webapp/src/main/resources/public/css/my-user-data-items.css
index 92b1129f3..fb2ae9f25 100644
--- a/webapp/src/main/resources/public/css/my-user-data-items.css
+++ b/webapp/src/main/resources/public/css/my-user-data-items.css
@@ -32,11 +32,12 @@
display: flex;
flex-direction: row;
justify-content: space-between;
+ column-gap: 9px;
height: 78px;
background-color: #e0d2bd;
margin-bottom: 8px;
margin-top: 8px;
- box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px;
+ box-shadow: rgba(0, 0, 0, 0.12) 0 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px;
border-radius: 3px;
border: 1px solid #343434;
width: 90%;
@@ -86,6 +87,7 @@
}
.my-game-item .left-pane,
+.my-game-item .variant-pane,
.my-game-item .middle-pane,
.my-game-item .right-pane {
display: flex;
@@ -94,9 +96,33 @@
}
.my-game-item .left-pane {
- width: 12%;
+ width: 74px;
+ flex: 0 0 74px;
}
+.my-game-item .left-pane .time-control-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+}
+
+.my-game-item .left-pane .time-control-duration-cell {
+ color: #3a3a3a;
+ font-size: 11px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.my-game-item .variant-pane {
+ flex-shrink: 0;
+ justify-content: flex-start;
+ margin-left: 18px;
+ box-sizing: border-box;
+}
+
+
.my-game-item .middle-pane {
flex: 1;
flex-direction: column;
@@ -110,12 +136,14 @@
}
.my-game-item .right-pane {
- width: 16%;
+ width: 136px;
+ flex: 0 0 136px;
flex-direction: column;
}
.my-game-item .indicator-pane {
- width: 9%;
+ width: 82px;
+ flex: 0 0 82px;
display: flex;
align-items: center;
justify-content: center;
@@ -212,7 +240,8 @@
@media (max-width: 1000px) {
.my-game-item {
- height: 120px;
+ min-height: 78px;
+ padding: 14px 0;
border-radius: 0;
width: 102%;
font-size: 26px;
@@ -229,8 +258,18 @@
height: 40px;
}
+ .my-game-item .left-pane {
+ width: 110px;
+ flex: 0 0 110px;
+ }
+
+ .my-game-item .left-pane .time-control-duration-cell {
+ font-size: 18px;
+ }
+
.my-game-item .indicator-pane {
- width: 16%;
+ width: 120px;
+ flex: 0 0 120px;
}
.my-game-item .outcome-label-holder {
@@ -240,13 +279,26 @@
}
.my-game-item .right-pane {
- width: 19%;
+ width: 190px;
+ flex: 0 0 190px;
}
.my-game-item .rating-mode {
font-size: 22px;
}
+ .my-game-item .variant-pane {
+ padding-left: 12px;
+ }
+
+ .my-game-item .variant-pane .variant-symbol {
+ font-size: 42px;
+ }
+
+ .my-game-item .variant-pane .variant-name {
+ font-size: 16px;
+ }
+
.my-game-item .game-status {
font-size: 24px;
}
diff --git a/webapp/src/main/resources/public/css/new-game-modal.css b/webapp/src/main/resources/public/css/new-game-modal.css
index 1f1cf9d11..0ca256136 100644
--- a/webapp/src/main/resources/public/css/new-game-modal.css
+++ b/webapp/src/main/resources/public/css/new-game-modal.css
@@ -116,7 +116,7 @@
width: 20px;
}
-#create-new-game-modal input[type=button].create-game-buttons {
+#create-new-game-modal .create-game-buttons {
width: 150px;
margin-bottom: 0;
}
diff --git a/webapp/src/main/resources/public/css/play-bot-modal.css b/webapp/src/main/resources/public/css/play-bot-modal.css
index 15aeca96a..5d37731e1 100644
--- a/webapp/src/main/resources/public/css/play-bot-modal.css
+++ b/webapp/src/main/resources/public/css/play-bot-modal.css
@@ -21,38 +21,102 @@
margin-top: 0;
}
-#play-bot-modal #play-bot-modal-table {
+#play-bot-modal {
+ width: 320px;
+}
+
+#play-bot-modal .new-game-main-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-flow: column;
+}
+
+#play-bot-modal .container-with-margin {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-flow: column;
+}
+
+#play-bot-modal .new-game-option-table {
+ border-collapse: separate;
+ border-spacing: 0;
+ table-layout: fixed;
+}
+
+#play-bot-modal .new-game-option-table td,
+#play-bot-modal .option-button-div {
+ height: 32px;
+}
+
+#play-bot-modal .new-game-option-table td.button-cells {
+ width: 114px;
+ padding: 0 4px;
+ height: 45px;
+}
+
+#play-bot-modal .option-button-div {
+ border: 2px solid #605f5f;
+ border-radius: 10px;
+ background-color: #f5eee5;
width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ margin: 0 auto;
+ cursor: pointer;
}
-#play-bot-modal #play-bot-modal-table td {
- padding: 5px;
- vertical-align: top;
+#play-bot-modal .option-button-div-selected {
+ background-color: #fcfaf8;
+ box-shadow: rgba(0, 0, 0, 0.09) 0 3px 3px, rgba(0, 0, 0, 0.12) 0 3px 3px;
+ font-weight: bold;
+ border-color: #124610;
+ outline: 1px solid #124610;
}
-#play-bot-modal #play-bot-modal-table td.left-cell {
- padding-left: 3px;
- padding-right: 15px;
- width: 45%;
+#play-bot-modal .option-button-div-disabled {
+ background-color: #c6c4c0;
+ cursor: not-allowed;
+ color: #5e5d5d;
}
-#play-bot-modal #play-bot-modal-table td.right-cell {
- padding-left: 15px;
- padding-right: 3px;
- width: 45%;
+#play-bot-modal .new-game-option-table td.icons-cells {
+ width: 45px;
}
-#start-fen {
+#play-bot-modal img.time-control-icons {
+ width: 28px;
+}
+
+#play-bot-modal .container-with-margin .modal-form-label {
width: 100%;
- text-align: center;
}
-#play-bot-modal .standard-radio .label-span {
- padding: 2px;
- padding-left: 2px;
+#play-bot-modal #play-bot-depth-box {
+ width: 100%;
+ margin-top: 12px;
+ margin-bottom: 10px;
+}
+
+#play-bot-modal #play-bot-depth {
+ width: 100%;
cursor: pointer;
}
+#play-bot-modal #play-bot-depth-value-box {
+ margin-top: 6px;
+ margin-bottom: 6px;
+ text-align: center;
+}
+
+#start-fen {
+ width: 100%;
+ text-align: center;
+}
+
#play-bot-modal #deeper-levels-message {
font-size: 18px;
margin-top: 16px;
@@ -60,19 +124,67 @@
width: 90%;
}
+#play-bot-modal .openings-heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+#play-bot-modal .openings-heading img {
+ height: 1em;
+ width: 1em;
+ vertical-align: middle;
+}
+
+@media (max-width: 1000px) {
+ #play-bot-modal .openings-heading img {
+ height: 1.2em;
+ width: 1.2em;
+ }
+}
+
@media (max-width: 1000px) {
#play-bot-modal {
- padding-left: 14px;
- padding-right: 14px;
+ width: 100%;
+ }
+
+ #play-bot-modal .container-with-margin {
+ width: 90%;
+ }
+
+ #play-bot-modal .option-button-div {
+ height: 64px;
+ font-size: 36px;
+ }
+
+ #play-bot-modal .new-game-option-table td.button-cells {
+ width: 200px;
+ padding-left: 6px;
+ padding-right: 6px;
+ height: 78px;
+ }
+
+ #play-bot-modal .new-game-option-table td.icons-cells {
+ width: 100px;
+ }
+
+ #play-bot-modal img.time-control-icons {
+ width: 50px;
}
#start-fen {
font-size: 27px;
}
+ #play-bot-modal #play-bot-depth-value-box {
+ margin-top: 36px;
+ margin-bottom: 10px;
+ font-size: 34px;
+ }
+
#play-bot-modal #deeper-levels-message {
font-size: 34px;
- margin-top: 20px;
+ margin-top: 36px;
margin-bottom: 14px;
}
}
diff --git a/webapp/src/main/resources/public/css/player-vs-player.css b/webapp/src/main/resources/public/css/player-vs-player.css
index d6194ca07..c2ea966e8 100644
--- a/webapp/src/main/resources/public/css/player-vs-player.css
+++ b/webapp/src/main/resources/public/css/player-vs-player.css
@@ -33,8 +33,13 @@ tr#outcome-row {
color: #515151;
}
+.pvp-variant-symbol {
+ font-size: 14px;
+ margin-right: 4px;
+ color: #343434;
+}
+
#game-info-info-box {
- height: 200px;
display: flex;
}
@@ -89,6 +94,39 @@ tr#outcome-row {
border-top-color: #4d4d4d;
}
+.mini-counter-box {
+ position: fixed;
+ bottom: 8px;
+ right: 8px;
+ z-index: 400;
+ width: 378px;
+ box-shadow: rgba(0, 0, 0, 0.4) 0 4px 10px;
+ border-radius: 4px;
+ overflow: hidden;
+ opacity: 1;
+ transition: opacity 0.2s ease-in-out;
+}
+
+.mini-counter-box.mini-counter-box-hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.mini-counter-box .mini-time-counter {
+ font-size: 46px;
+ padding-top: 4px;
+ padding-bottom: 2px;
+ box-sizing: border-box;
+}
+
+.mini-counter-box .red-counter {
+ background-color: rgb(255 168 168);
+}
+
+.mini-counter-box .black-counter {
+ background-color: rgb(171 171 171);
+}
+
.move-tree-container-smaller {
height: 270px;
}
@@ -142,12 +180,12 @@ tr#outcome-row {
font-size: 46px;
}
- #analysis-button-container-right-side {
- margin-top: 36px;
+ #counter-box {
+ max-width: 420px;
}
- #game-info-info-box {
- height: 420px;
+ #analysis-button-container-right-side {
+ margin-top: 36px;
}
#perpetual-checking-tracker-info-box {
diff --git a/webapp/src/main/resources/public/css/style-reactive.css b/webapp/src/main/resources/public/css/style-reactive.css
index 4dc4d2846..9a0ef5524 100644
--- a/webapp/src/main/resources/public/css/style-reactive.css
+++ b/webapp/src/main/resources/public/css/style-reactive.css
@@ -44,6 +44,7 @@
#user-profile-icon {
display: block;
+ margin-right: 0;
}
@@ -79,10 +80,12 @@
.main-container,
.main-container-text,
+ .main-container-text-extended-smaller,
.main-container-text-extended,
.main-container-text-extended-larger,
.main-container-text-error-message {
width: 96%;
+ min-width: 0;
}
.standard-data-table .user-rating-delta-value-box,
@@ -92,6 +95,10 @@
font-size: 18px;
}
+ textarea {
+ font-size: 28px;
+ }
+
input[type="checkbox"],
input[type="radio"] {
-ms-transform: scale(2); /* IE 9 */
@@ -201,6 +208,11 @@
margin-right: 0;
}
+ .game-thumb-variant {
+ font-size: 35px;
+ margin-right: 8px;
+ }
+
.game-thumb-info-box {
margin-bottom: 18px;
}
@@ -211,6 +223,10 @@
font-size: 30px;
}
+ .game-thumb-player-name {
+ max-width: 260px;
+ }
+
.game-thumb-last-updated {
font-size: 26px;
}
@@ -333,8 +349,8 @@
}
#user-profile-icon img {
- width: 30px;
- height: 30px;
+ width: 50px;
+ height: 50px;
}
#handle-menu-container {
@@ -351,39 +367,39 @@
font-size: 30px;
}
- input[type=button].app-buttons,
- input[type=button].app-buttons-red,
- input[type=button].app-buttons-bigger,
- input[type=button].app-buttons-much-bigger {
+ .app-buttons,
+ .app-buttons-red,
+ .app-buttons-bigger,
+ .app-buttons-much-bigger {
border-radius: 6px;
}
/* use the color of :hover (since there is no hover on mobile) */
- input[type=button].app-buttons,
- input[type=button].app-buttons-bigger,
- input[type=button].app-buttons-much-bigger {
+ .app-buttons,
+ .app-buttons-bigger,
+ .app-buttons-much-bigger {
background-color: #077d01;
}
- input[type=button].app-buttons-disabled,
- input[type=button].app-buttons-disabled:hover {
+ .app-buttons-disabled,
+ .app-buttons-disabled:hover {
background-color: #848484;
}
- input[type=button].app-buttons,
- input[type=button].app-buttons-red {
+ .app-buttons,
+ .app-buttons-red {
font-size: 30px;
height: 55px;
min-width: 180px;
}
- input[type=button].app-buttons-bigger {
+ .app-buttons-bigger {
font-size: 34px;
height: 60px;
min-width: 200px;
}
- input[type=button].app-buttons-much-bigger {
+ .app-buttons-much-bigger {
font-size: 36px;
height: 66px;
margin-top: 30px;
@@ -391,7 +407,7 @@
min-width: 220px;
}
- input[type=button].app-buttons-smaller {
+ .app-buttons-smaller {
font-size: 20px;
height: 54px;
}
@@ -509,8 +525,41 @@
width: 68px;
}
+ #settings-info-box-container #advanced-settings-toggle {
+ font-size: 24px;
+ }
+
+ #settings-info-box-container #advanced-settings-box {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ width: calc(100vw - 24px);
+ max-height: calc(100vh - 24px);
+ transform: translate(-50%, -50%);
+ z-index: 1202;
+ overflow-y: auto;
+ border-radius: 8px;
+ padding: 76px 34px 34px;
+ }
+
+ #settings-info-box-container #advanced-settings-close-button {
+ display: block;
+ position: absolute;
+ top: 20px;
+ right: 24px;
+ width: 44px;
+ height: 44px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font-size: 40px;
+ line-height: 1;
+ cursor: pointer;
+ }
+
#settings-info-box img#flip-board-button,
#settings-info-box img#show-coordinates-button,
+ #settings-info-box img#colorblind-friendly-black-pieces-button,
#settings-info-box img#show-analytics-arrows {
width: 55px;
}
@@ -529,6 +578,32 @@
width: 88px;
}
+ #settings-info-box-container #advanced-settings-box .advanced-setting-row {
+ margin: 10px 0;
+ }
+
+ #settings-info-box-container #advanced-settings-box .advanced-setting-icon {
+ width: 34px;
+ margin-right: 12px;
+ }
+
+ #settings-info-box-container #advanced-settings-box .advanced-setting-title {
+ font-size: 32px;
+ }
+
+ #settings-info-box-container #advanced-settings-box .advanced-setting-description {
+ font-size: 20px;
+ }
+
+ #settings-info-box-container #advanced-settings-box .advanced-setting-options {
+ gap: 8px;
+ font-size: 30px;
+ }
+
+ #settings-info-box-container #advanced-settings-box .advanced-setting-options input[type=radio] {
+ transform: scale(1.8);
+ }
+
#settings-info-box .setting-option-double-item {
width: 140px;
}
diff --git a/webapp/src/main/resources/public/css/style.css b/webapp/src/main/resources/public/css/style.css
index afea6ce4d..3c089007d 100644
--- a/webapp/src/main/resources/public/css/style.css
+++ b/webapp/src/main/resources/public/css/style.css
@@ -133,6 +133,37 @@ img.winner-icon {
margin-right: 4px;
}
+.game-thumb-left-section {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ flex: 1;
+}
+
+.game-thumb-left-section > div {
+ min-width: 0;
+}
+
+.game-thumb-left-section > div > div {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.game-thumb-player-name {
+ display: inline-block;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.game-thumb-variant {
+ font-size: 18px;
+ line-height: 1;
+}
+
.game-thumb-status {
display: flex;
justify-content: end;
@@ -239,6 +270,28 @@ img.winner-icon {
white-space: nowrap;
}
+.variant-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+}
+
+.variant-symbol {
+ font-size: 24px;
+ color: #343434;
+ line-height: 1;
+}
+
+.variant-name {
+ color: #3a3a3a;
+ font-size: 10px;
+ line-height: 1;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
table.standard-data-table {
border-spacing: 0;
border-collapse: collapse;
@@ -471,6 +524,12 @@ span.sub-text a, span.sub-text a:visited {
background-color: #b2d7f6;
}
+.drop-down-menu-item-link,
+.drop-down-menu-item-link:visited {
+ color: #2b2b2b;
+ text-decoration: none;
+}
+
.drop-down-menu-item-top-separator {
border-top: 1px solid rgba(43, 43, 43, 0.67);
}
@@ -786,28 +845,26 @@ a.top-bar-menu-items:hover img.top-bar-menu-items-icons {
}
#user-profile-icon {
- display: none;
+ display: block;
+ margin-right: 12px;
+ margin-top: 5px;
}
#user-profile-icon img {
- width: 18px;
- height: 18px;
- opacity: 60%;
-}
-
-#user-profile-icon img:hover {
- opacity: 80%;
+ width: 20px;
+ height: 20px;
+ opacity: 50%;
}
#login-side input {
float: right;
}
-input[type=button].app-buttons,
-input[type=button].app-buttons-red,
-input[type=button].app-buttons-smaller,
-input[type=button].app-buttons-bigger,
-input[type=button].app-buttons-much-bigger {
+.app-buttons,
+.app-buttons-red,
+.app-buttons-smaller,
+.app-buttons-bigger,
+.app-buttons-much-bigger {
padding: 5px 20px;
text-decoration: none;
margin: 4px 2px;
@@ -819,68 +876,68 @@ input[type=button].app-buttons-much-bigger {
height: 28px;
}
-input[type=button].app-buttons,
-input[type=button].app-buttons-smaller,
-input[type=button].app-buttons-bigger,
-input[type=button].app-buttons-much-bigger {
+.app-buttons,
+.app-buttons-smaller,
+.app-buttons-bigger,
+.app-buttons-much-bigger {
background-color: #054101;
color: white;
}
-input[type=button].app-buttons:hover,
-input[type=button].app-buttons-smaller,
-input[type=button].app-buttons-bigger:hover,
-input[type=button].app-buttons-much-bigger:hover {
+.app-buttons:hover,
+.app-buttons-smaller,
+.app-buttons-bigger:hover,
+.app-buttons-much-bigger:hover {
background-color: #077d01;
cursor: pointer;
}
-input[type=button].app-buttons-red,
-input[type=button].app-buttons-bigger-red {
+.app-buttons-red,
+.app-buttons-bigger-red {
background-color: #a80202;
color: white;
}
-input[type=button].app-buttons-red:hover,
-input[type=button].app-buttons-bigger-red:hover {
+.app-buttons-red:hover,
+.app-buttons-bigger-red:hover {
background-color: #ce0707;
cursor: pointer;
}
-input[type=button].app-buttons-smaller {
+.app-buttons-smaller {
font-size: 13px;
height: 26px;
margin-top: 2px;
margin-bottom: 2px;
}
-input[type=button].app-buttons-bigger {
+.app-buttons-bigger {
font-size: 16px;
height: 32px;
margin-top: 14px;
margin-bottom: 14px;
}
-input[type=button].app-buttons-much-bigger {
+.app-buttons-much-bigger {
font-size: 20px;
height: 40px;
margin-top: 22px;
margin-bottom: 22px;
}
-input[type=button].log-buttons {
+.log-buttons {
width: 96px;
min-width: 96px;
display: block;
margin-left: 10px;
}
-input[type=button].app-buttons-disabled,
-input[type=button].app-buttons-disabled:hover {
+.app-buttons-disabled,
+.app-buttons-disabled:hover {
background-color: #848484;
}
-input[type=button].app-buttons-disabled:hover {
+.app-buttons-disabled:hover {
cursor: unset;
}
@@ -1031,6 +1088,7 @@ input[type=password].incorrect-data {
.main-container,
.main-container-text,
+.main-container-text-extended-smaller,
.main-container-text-extended,
.main-container-text-extended-larger,
.main-container-text-error-message {
@@ -1039,6 +1097,7 @@ input[type=password].incorrect-data {
}
.main-container-text,
+.main-container-text-extended-smaller,
.main-container-text-extended,
.main-container-text-extended-larger,
.main-container-text-error-message {
@@ -1052,6 +1111,12 @@ input[type=password].incorrect-data {
max-width: 950px;
}
+.main-container-text-extended-smaller {
+ width: 70%;
+ min-width: 600px;
+ max-width: 1200px;
+}
+
.main-container-text-extended {
width: 86%;
min-width: 600px;
@@ -1281,6 +1346,8 @@ table.info-box-sub-table td.values {
display: flex;
align-items: center;
justify-content: center;
+ position: relative;
+ overflow: visible;
}
#settings-info-box {
@@ -1288,6 +1355,7 @@ table.info-box-sub-table td.values {
align-items: center;
justify-content: space-between;
height: 40px;
+ position: relative;
}
#settings-info-box .setting-option-item,
@@ -1321,7 +1389,8 @@ table.info-box-sub-table td.values {
}
#settings-info-box img#flip-board-button,
-#settings-info-box img#show-coordinates-button {
+#settings-info-box img#show-coordinates-button,
+#settings-info-box img#colorblind-friendly-black-pieces-button {
width: 24px;
}
@@ -1329,6 +1398,117 @@ table.info-box-sub-table td.values {
width: 30px;
}
+#settings-info-box-container #advanced-settings-toggle {
+ position: absolute;
+ right: 12px;
+ bottom: 6px;
+ color: #3b3b3b;
+ text-decoration: underline;
+ font-size: 14px;
+ font-style: italic;
+ font-weight: bold;
+ line-height: 1;
+ z-index: 100;
+}
+
+#settings-info-box-container #advanced-settings-toggle:hover {
+ cursor: pointer;
+}
+
+#settings-info-box-container #advanced-settings-box {
+ display: none;
+ position: absolute;
+ top: calc(100% + 10px);
+ left: -5px;
+ width: calc(100% + 10px);
+ z-index: 1200;
+ background: #e0d2bd;
+ border: 2px solid #393939;
+ border-radius: 6px;
+ box-shadow: rgba(0, 0, 0, 0.55) 0 4px 12px;
+ padding: 12px 19px;
+ box-sizing: border-box;
+}
+
+#settings-info-box-container #advanced-settings-close-button {
+ display: none;
+}
+
+#settings-info-box-container #advanced-settings-box.advanced-settings-box-open {
+ display: block;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-row {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+ margin: 6px 0;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-icon {
+ width: 20px;
+ margin-right: 8px;
+ margin-top: 2px;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-content {
+ flex: 1;
+ min-width: 0;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-text {
+ text-align: left;
+ margin-bottom: 4px;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-title {
+ color: #3b3b3b;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-description {
+ color: #50483d;
+ font-size: 13px;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-options {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ font-size: 14px;
+ color: #2f2f2f;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-options label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-options input[type=radio] {
+ cursor: pointer;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-options .advanced-setting-option-disabled,
+#settings-info-box-container #advanced-settings-box .advanced-setting-options .advanced-setting-option-disabled input[type=radio] {
+ color: #9a9a9a;
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+#settings-info-box-container #advanced-settings-box .advanced-setting-warning {
+ margin-top: 6px;
+ padding: 4px 8px;
+ font-size: 12px;
+ color: #8a5a00;
+ background-color: #fff7e0;
+ border: 1px solid #f0d68b;
+ border-radius: 3px;
+}
+
#settings-info-box #show-analytics-arrows-item {
display: none;
}
@@ -1376,18 +1556,33 @@ table.info-box-sub-table td.values {
margin-bottom: 4px;
}
+#eval-line-chart-container {
+ margin-top: 20px;
+ margin-bottom: 6px;
+}
+
#chat-box-container {
border: 1px solid black;
margin-bottom: 15px;
height: 270px;
box-shadow: rgba(67, 71, 85, 0.27) 0px 0px 0.25em, rgba(90, 125, 188, 0.05) 0px 0.25em 1em;
background-color: #ece8e3;
+ display: flex;
+ flex-direction: column;
}
#chat-box-messages-container {
overflow-y: scroll;
overflow-x: hidden;
- height: 230px;
+ flex: 1;
+}
+
+#chat-box-typing-indicator {
+ padding: 2px 8px;
+ font-size: 14px;
+ font-style: italic;
+ color: #555;
+ min-height: 0;
}
#chat-box-send-container {
@@ -1415,6 +1610,10 @@ table.info-box-sub-table td.values {
padding-bottom: 6px;
}
+.chat-box-message-highlighted {
+ background-color: #fff7cc;
+}
+
.chat-box-message .header {
display: flex;
width: 100%;
@@ -1478,6 +1677,12 @@ table.info-box-sub-table td.values {
padding-right: 6px;
}
+.perpetual-checking-info-box-sub-boxes.perpetual-checking-limit-warning {
+ color: rgb(138 0 0);
+ background-color: rgb(255 0 0 / 12%);
+ border-radius: 4px;
+}
+
.action-link {
color: #0505f0;
cursor: pointer;
@@ -1669,7 +1874,6 @@ img#navigation-end {
}
-
/* bar indicators */
.bar-indicator {
@@ -2062,13 +2266,14 @@ img#navigation-end {
padding-bottom: 6px;
}
-.guest-message-box a {
+.guest-message-box a,
+.guest-message-box .action-link {
font-weight: bold;
}
.time-control-icons {
width: 22px;
- opacity: 78%;
+ opacity: 80%;
}
.time-control-icons-larger {
diff --git a/webapp/src/main/resources/public/js/simple-board.js b/webapp/src/main/resources/public/css/test-board.css
similarity index 74%
rename from webapp/src/main/resources/public/js/simple-board.js
rename to webapp/src/main/resources/public/css/test-board.css
index 6b5d7b7bb..e7256d9fc 100644
--- a/webapp/src/main/resources/public/js/simple-board.js
+++ b/webapp/src/main/resources/public/css/test-board.css
@@ -17,15 +17,6 @@
* along with this program. If not, see
.
*/
-class SimpleBoardPage extends BasePage {
-
- constructor() {
- super();
- const boardGui = createWebappBoardGui();
- boardGui.loadFen(DEFAULT_START_FEN);
- new SettingsGui(boardGui, null);
- }
-
+#manchu-explanation {
+ display: none;
}
-
-window.onload = () => new SimpleBoardPage();
diff --git a/webapp/src/main/resources/public/css/user-profile.css b/webapp/src/main/resources/public/css/user-profile.css
index 24e7a0c57..1d733000a 100644
--- a/webapp/src/main/resources/public/css/user-profile.css
+++ b/webapp/src/main/resources/public/css/user-profile.css
@@ -19,6 +19,8 @@
.main-container-text {
margin-top: 0;
+ /* when profile is empty (no puzzle, no game), the footer looks huge */
+ min-height: 800px;
}
h1 {
@@ -140,7 +142,35 @@ h2 {
font-weight: normal;
}
-.online-status-indicator {
+#ratings-table .category-label-row th {
+ text-align: center;
+ font-style: normal;
+}
+
+#ratings-table .category-label-row th:first-child {
+ text-align: right;
+}
+
+#ratings-table .wdl-cell {
+ white-space: nowrap;
+}
+
+#ratings-table .wdl-win {
+ color: #19681d;
+ font-weight: bold;
+}
+
+#ratings-table .wdl-draw {
+ color: #1a1a1a;
+ font-weight: bold;
+}
+
+#ratings-table .wdl-loss {
+ color: #b5180d;
+ font-weight: bold;
+}
+
+#profile-header .online-status-indicator {
margin-right: 0;
}
@@ -149,6 +179,12 @@ h2 {
width: 90%;
}
+.browse-more-link-container {
+ width: 100%;
+ margin-top: 10px;
+ margin-bottom: 20px;
+}
+
@media (max-width: 1000px) {
h2 {
font-size: 36px;
@@ -217,4 +253,8 @@ h2 {
.puzzle-chart-container {
width: 98%;
}
+
+ .main-container-text {
+ min-height: unset;
+ }
}
diff --git a/webapp/src/main/resources/public/css/user-settings.css b/webapp/src/main/resources/public/css/user-settings.css
index ebbb3b971..ad35ef108 100644
--- a/webapp/src/main/resources/public/css/user-settings.css
+++ b/webapp/src/main/resources/public/css/user-settings.css
@@ -48,6 +48,10 @@
margin-left: 13px;
}
+#resend-email-confirmation-container {
+ margin: 0 15px 15px 13px;
+}
+
#email-address {
font-size: 18px;
height: 30px;
@@ -90,6 +94,51 @@
max-width: 500px;
}
+#sessions-actions-container {
+ margin: 10px 0 12px 12px;
+}
+
+#sessions-actions-container input[type="button"] {
+ margin-left: 12px;
+}
+
+#user-sessions-table-container {
+ overflow-x: auto;
+}
+
+#user-sessions-table {
+ margin-left: 8px;
+}
+
+#user-sessions-table th,
+#user-sessions-table td {
+ white-space: nowrap;
+}
+
+
+.session-country-cell,
+.session-os-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.session-os-icon {
+ width: 20px;
+ height: 20px;
+ object-fit: contain;
+ text-align: center;
+}
+
+#user-sessions-table .select-cell {
+ width: 26px;
+}
+
+#user-sessions-empty-message,
+#all-sessions-link {
+ margin-left: 12px;
+}
+
.hidden {
display: none;
}
@@ -138,4 +187,20 @@
.profile-picture-help {
font-size: 28px;
}
+
+ #sessions-actions-container {
+ font-size: 30px;
+ margin-left: 10px;
+ }
+
+
+ #sessions-actions-container input[type="button"] {
+ margin-left: 0;
+ margin-top: 10px;
+ }
+ .session-os-icon {
+ width: 34px;
+ height: 34px;
+ height: 34px;
+ }
}
diff --git a/webapp/src/main/resources/public/dist/0.0.3/board-gui.js b/webapp/src/main/resources/public/dist/0.0.3/board-gui.js
new file mode 100644
index 000000000..b9fa5b945
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.3/board-gui.js
@@ -0,0 +1,1735 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see
.
+ */
+
+// Small DOM helpers and constants, inlined here so this file is usable on its
+// own (only requires xiangqi.js, which provides the `Color` enum). Previously
+// imported from utils.js / ui.js. For helpers that may also be defined by
+// those modules when loaded as part of the full webapp, we only define them
+// if the host page hasn't already loaded those modules, to avoid clobbering
+// existing definitions.
+if (typeof buildImg === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildImg = function (src, className = null) {
+ const img = document.createElement('img');
+ img.src = src;
+ if (className != null) img.className = className;
+ return img;
+ };
+}
+
+if (typeof htmlCollectionToArray === 'undefined') {
+ // eslint-disable-next-line no-var
+ var htmlCollectionToArray = function (htmlCollection) {
+ const arr = [];
+ for (let i = 0; i < htmlCollection.length; i++) {
+ arr.push(htmlCollection[i]);
+ }
+ return arr;
+ };
+}
+
+if (typeof buildDivWithClass === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildDivWithClass = function (className) {
+ const div = document.createElement('div');
+ div.className = className;
+ return div;
+ };
+}
+
+// Mapping from FEN piece char to piece image file name. Lives here (rather
+// than in ui.js) so that board-gui.js can be used standalone.
+const pieceImageNames = new Map();
+pieceImageNames.set('K', 'red_general.png');
+pieceImageNames.set('k', 'black_general.png');
+pieceImageNames.set('A', 'red_advisor.png');
+pieceImageNames.set('a', 'black_advisor.png');
+pieceImageNames.set('B', 'red_elephant.png');
+pieceImageNames.set('b', 'black_elephant.png');
+pieceImageNames.set('N', 'red_horse.png');
+pieceImageNames.set('n', 'black_horse.png');
+pieceImageNames.set('C', 'red_cannon.png');
+pieceImageNames.set('c', 'black_cannon.png');
+pieceImageNames.set('R', 'red_chariot.png');
+pieceImageNames.set('r', 'black_chariot.png');
+pieceImageNames.set('P', 'red_soldier.png');
+pieceImageNames.set('p', 'black_soldier.png');
+
+const diagonalDescending = '
';
+const diagonalRising = '
';
+
+const diagonalDescendingMini = '
';
+const diagonalRisingMini = '
';
+
+const PRIMARY_ARROW_COLOR = 'rgb(27, 181, 29)';
+const SECONDARY_ARROW_COLOR = 'rgb(62, 56, 219)';
+
+const ARROW_MARKERS = `
+
+
+
+
+
+
+
+ `;
+
+const bottomRightCrosshairs = [
+ new Position(0, 8), new Position(6, 8),
+ new Position(1, 7), new Position(3, 7), new Position(5, 7), new Position(7, 7),
+ new Position(1, 4), new Position(3, 4), new Position(5, 4), new Position(7, 4),
+ new Position(0, 3), new Position(6, 3),
+];
+
+const bottomLeftCrosshairs = [
+ new Position(1, 8), new Position(7, 8),
+ new Position(0, 7), new Position(2, 7), new Position(4, 7), new Position(6, 7),
+ new Position(0, 4), new Position(2, 4), new Position(4, 4), new Position(6, 4),
+ new Position(1, 3), new Position(7, 3),
+];
+
+const topRightCrosshairs = [
+ new Position(0, 7), new Position(6, 7),
+ new Position(1, 6), new Position(3, 6), new Position(5, 6), new Position(7, 6),
+ new Position(1, 3), new Position(3, 3), new Position(5, 3), new Position(7, 3),
+ new Position(0, 2), new Position(6, 2),
+];
+
+const topLeftCrosshairs = [
+ new Position(1, 7), new Position(7, 7),
+ new Position(0, 6), new Position(2, 6), new Position(4, 6), new Position(6, 6),
+ new Position(0, 3), new Position(2, 3), new Position(4, 3), new Position(6, 3),
+ new Position(1, 2), new Position(7, 2)
+];
+
+const EngineArrowType = Object.freeze({
+ PRIMARY: 'PRIMARY',
+ SECONDARY: 'SECONDARY'
+});
+
+/**
+ * Orientation of the board coordinate labels.
+ * Use `null` (not part of this enum) to hide the coordinates entirely.
+ */
+const CoordinatesOrientation = Object.freeze({
+ WXF: 'WXF',
+ UCI: 'UCI',
+});
+
+/**
+ * Visual style of the board pieces. Used to pick the corresponding image folder
+ * (under `${assetsBaseUrl}/images/pieces/
/`).
+ */
+const PieceStyleSetting = Object.freeze({
+ TRADITIONAL: 'TRADITIONAL',
+ ROMANIZED_ROUNDED: 'ROMANIZED_ROUNDED',
+ DEFAULT: 'TRADITIONAL',
+});
+
+/**
+ * @typedef {Object} BoardGuiOptions
+ * @property {string} [elementId] - id of the container element
+ * @property {boolean} [showCoordinates] - whether to reserve space for file/rank coordinates
+ * @property {string|null} [coordinatesOrientation] - one of {@link CoordinatesOrientation} or `null` to
+ * hide the labels (space is still reserved when
+ * `showCoordinates` is true). The caller is in
+ * charge of resolving any user preference (e.g. cookies).
+ * @property {boolean} [mini] - whether this is a mini (thumb) board
+ * @property {boolean} [forceRenderChecks] - render checks even on mini boards
+ * @property {boolean} [svg] - enable the SVG overlay (used for engine arrows)
+ * @property {string} [assetsBaseUrl] - base URL prepended to every static asset path
+ * (images, audio). Default: `https://elephantchess.io`.
+ * Pass an empty string to use relative paths (e.g. when
+ * serving the assets from the current host on localhost).
+ * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece
+ * image folder.
+ */
+
+/** @type {Readonly>} */
+const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({
+ elementId: 'board-container',
+ showCoordinates: true,
+ coordinatesOrientation: 'WXF',
+ mini: false,
+ forceRenderChecks: false,
+ svg: false,
+ assetsBaseUrl: 'https://cdn.elephantchess.io/static',
+ pieceStyle: PieceStyleSetting.DEFAULT,
+});
+
+class BoardGui {
+
+ #board = new Board();
+ #boardContainer;
+ #currentShowingLegalMovesFor = null;
+ #selectedPiecePosition = null;
+ #flippedRed = true; // if true, oriented toward the RED player
+ #afterMoveListeners = [];
+ #afterDrawPositionsListeners = [];
+ #afterFlipListeners = [];
+
+ /** @type {HTMLAudioElement} */
+ #clickSound;
+
+ // when false, player won't be able to make a move
+ #isPlayerMoveEnabled = true;
+
+ /**
+ * @type {Readonly>}
+ */
+ #options;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #primaryEngineArrow = null;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #secondaryEngineArrow = null;
+
+ #isSafari = false;
+
+ /**
+ * @param {BoardGuiOptions} [options]
+ */
+ constructor(options = {}) {
+ this.#isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ this.#options = Object.freeze({...DEFAULT_BOARD_GUI_OPTIONS, ...options});
+
+ this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`);
+
+ this.#boardContainer = document.getElementById(this.#options.elementId);
+ this.#drawBoard();
+ this.#drawPieces(); // FIXME: useful?
+
+ const boardGui = this;
+
+ document
+ .getElementsByTagName('html')
+ .item(0)
+ .addEventListener('click', (e) => {
+ if (!boardGui.#boardContainer.contains(e.target)) {
+ boardGui.#hideAllPiecePlaceHolders();
+ }
+ });
+
+ if (this.#options.svg) {
+ window.onresize = function () {
+ boardGui.#renderSvg();
+ };
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Draw the position described by {@code fen}.
+ *
+ * When {@code animate} is true, the transition from the currently displayed
+ * position to the target position is animated: unchanged pieces are left in
+ * place, pieces of the same type are paired between the current and target
+ * positions and slide from their source square to their destination square,
+ * leftover current pieces fade out, and target pieces fade in - all
+ * simultaneously. This works for any diff size (not just a single move),
+ * similar to what lichess does.
+ *
+ * @param fen {string}
+ * @param animate {boolean}
+ */
+ loadFen(fen, animate = false) {
+ if (this.isInPlaceHolderMode()) {
+ console.warn('Can not load FEN when in placeholder mode');
+ return;
+ }
+
+ if (animate) {
+ this.#drawPositionAnimated(fen);
+ } else {
+ this.#drawPositionNoAnimation(fen);
+ }
+ }
+
+ #drawPositionNoAnimation(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+ this.#unDrawAllPieces();
+ this.#board.loadFen(targetFen);
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Compute the target position (via a temporary board so we don't mutate the
+ * current {@link Board} model before we're ready) and diff it against the
+ * currently displayed pieces to build the animation plan.
+ *
+ * @param targetFen {string}
+ */
+ #drawPositionAnimated(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+
+ // compute the target piece layout via a temp board (no DOM side-effects,
+ // and no mutation of the real model yet)
+ const targetBoard = new Board();
+ targetBoard.loadFen(targetFen);
+ const targetPieces = targetBoard.listPiecePositions();
+
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ // commit the new board state in the model
+ this.#board.loadFen(targetFen);
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#afterDrawPositionsListeners.forEach(listener => listener());
+ });
+ }
+
+ outputFen() {
+ return this.#board.outputFen();
+ }
+
+ /**
+ * Returns a copy of the underlying (non-GUI) {@link Board}, useful when
+ * callers need to inspect/query the board model without touching the DOM.
+ *
+ * A copy is returned rather than the internal instance to preserve
+ * encapsulation (so callers can't mutate the BoardGui's state directly).
+ *
+ * @return {Board}
+ */
+ get board() {
+ return this.#board.copy();
+ }
+
+ /**
+ * Change which color is to play next (doesn't alter the pieces on the board).
+ *
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#board.forceColorToPlay(color);
+ }
+
+ get isPlayerMoveEnabled() {
+ return this.#isPlayerMoveEnabled;
+ }
+
+ set isPlayerMoveEnabled(value) {
+ if (value) {
+ this.enablePlayerMove();
+ } else {
+ this.disablePlayerMove();
+ }
+ }
+
+ disablePlayerMove() {
+ this.#isPlayerMoveEnabled = false;
+ this.#hideAllPiecePlaceHolders();
+ this.#hideAllDraggableCursors();
+ }
+
+ enablePlayerMove() {
+ this.#isPlayerMoveEnabled = true;
+ this.#resetDraggableCursors();
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterMoveListener(listener) {
+ this.#afterMoveListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterDrawPositionsListener(listener) {
+ this.#afterDrawPositionsListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function(string)}
+ */
+ addAfterFlipListener(listener) {
+ this.#afterFlipListeners.push(listener);
+ }
+
+ clearAllAfterMovesListeners() {
+ this.#afterMoveListeners = [];
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param highLastMove {boolean} if true, the last move will be highlighted with a green dashed circle
+ * @param afterMoveCallback {function}
+ */
+ registerOpponentMove(move, highLastMove, afterMoveCallback) {
+ this.#hideHighlightedLastMove();
+ this.#animateMoveViaDiff(move, () => {
+ if (highLastMove) {
+ this.highlightLastMove(move);
+ }
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#clickSound
+ .play()
+ .catch(e => {
+ // ignored, spam error in console in dev
+ });
+ if (afterMoveCallback != null) {
+ afterMoveCallback()
+ }
+ });
+ }
+
+ /**
+ * Animate the DOM pieces from the currently displayed position (as
+ * described by {@link #board}) to {@code targetPieces}. Does NOT mutate the
+ * underlying board model; the caller is expected to update the model in
+ * {@code onDone}.
+ *
+ * Diff strategy (mirrors lichess/chessground):
+ * - pieces of the same pieceChar on the same square: leave image alone
+ * - current-board misplacements and target-board misplacements of the
+ * same pieceChar: paired greedily by shortest distance and animated
+ * with a CSS transform transition
+ * - unpaired leftover current-board misplacements: faded out
+ * - unpaired leftover target-board misplacements: faded in
+ *
+ * @param targetPieces {PieceAtPosition[]}
+ * @param durationMs {number}
+ * @param onDone {function}
+ */
+ #animatePiecesTo(targetPieces, durationMs, onDone) {
+ const currentPieces = this.#board.listPiecePositions();
+
+ const posKey = p => `${p.toUci()}`;
+ const currentMap = new Map(currentPieces.map(pp => [posKey(pp.position), pp]));
+ const targetMap = new Map(targetPieces.map(pp => [posKey(pp.position), pp]));
+
+ // pieces currently drawn on the board that don't match the target
+ // position (either the target expects a different piece on that
+ // square, or no piece at all). Each will be either animated to a
+ // square in targetBoardMisplacements or faded out.
+ /** @type {PieceAtPosition[]} */
+ const currentBoardMisplacements = [];
+ for (const [positionKey, currentPp] of currentMap) {
+ const targetPp = targetMap.get(positionKey);
+ if (targetPp && targetPp.piece.pieceChar === currentPp.piece.pieceChar) {
+ // same piece on same square: leave image alone
+ targetMap.delete(positionKey);
+ } else {
+ currentBoardMisplacements.push(currentPp);
+ }
+ }
+
+ // pieces required by the target position that aren't already correctly
+ // drawn on the current board (the square is empty or holds a different
+ // piece). Each will be either the destination of an animated move from
+ // currentBoardMisplacements or faded in as a new image.
+ /** @type {PieceAtPosition[]} */
+ const targetBoardMisplacements = Array.from(targetMap.values());
+
+ // pair movers greedily by closest same-pieceChar distance
+ const calculateDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+ const candidatePairs = [];
+ currentBoardMisplacements.forEach((c, i) => {
+ targetBoardMisplacements.forEach((t, j) => {
+ if (t.piece.pieceChar === c.piece.pieceChar) {
+ candidatePairs.push({i, j, distance: calculateDistance(c.position, t.position)});
+ }
+ });
+ });
+ candidatePairs.sort((a, b) => a.distance - b.distance);
+
+ // indices into currentBoardMisplacements / targetBoardMisplacements that
+ // have already been claimed by an animated move, so we don't pair the
+ // same piece twice while walking the distance-sorted candidate list.
+ /** @type {Set} */
+ const usedCurrent = new Set();
+ /** @type {Set} */
+ const usedTarget = new Set();
+ const animatedMoves = [];
+ for (const {i, j} of candidatePairs) {
+ if (!usedCurrent.has(i) && !usedTarget.has(j)) {
+ usedCurrent.add(i);
+ usedTarget.add(j);
+ animatedMoves.push({
+ from: currentBoardMisplacements[i].position,
+ to: targetBoardMisplacements[j].position,
+ pieceChar: currentBoardMisplacements[i].piece.pieceChar,
+ });
+ }
+ }
+ const toRemove = currentBoardMisplacements.filter((_, i) => !usedCurrent.has(i));
+ const toAdd = targetBoardMisplacements.filter((_, j) => !usedTarget.has(j));
+
+ // nothing to animate: bail out early
+ if (animatedMoves.length === 0 && toRemove.length === 0 && toAdd.length === 0) {
+ onDone();
+ return;
+ }
+
+ const transitionStr = `transform ${durationMs}ms ease, opacity ${durationMs}ms ease`;
+ const getSquareRect = pos => document
+ .getElementById(this.#positionToElementId('square', pos))
+ .getBoundingClientRect();
+
+ const cleanups = [];
+
+ // animate movers: translate the source-square image toward the target square
+ animatedMoves.forEach(animatedMove => {
+ const img = document.getElementById(this.#positionToElementId('image', animatedMove.from));
+ if (img == null) return;
+ const fromRect = getSquareRect(animatedMove.from);
+ const toRect = getSquareRect(animatedMove.to);
+ const dx = toRect.left - fromRect.left;
+ const dy = toRect.top - fromRect.top;
+
+ // rename id so it doesn't conflict with drawPieceAt on the target square
+ img.id = `animating-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ // must stay above .crosshair-square (z-index: 80) and resting
+ // pieces (z-index: 100) so the moving piece isn't painted under
+ // crosshairs of neighboring squares it flies over
+ img.style.zIndex = '110';
+ img.style.pointerEvents = 'none';
+
+ // trigger on next frame so the transition actually runs
+ requestAnimationFrame(() => {
+ img.style.transform = `translate(${dx}px, ${dy}px)`;
+ });
+
+ cleanups.push(() => img.remove());
+ });
+
+ // fade out leftover current pieces (captures / removals)
+ toRemove.forEach(rm => {
+ const img = document.getElementById(this.#positionToElementId('image', rm.position));
+ if (img == null) return;
+ img.id = `fading-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ img.style.pointerEvents = 'none';
+ requestAnimationFrame(() => {
+ img.style.opacity = '0';
+ });
+ cleanups.push(() => img.remove());
+ });
+
+ // fade in leftover target pieces (newly placed on a previously empty square)
+ const addedImages = [];
+ toAdd.forEach(add => {
+ this.#drawPieceAt(add.piece.pieceChar, add.position);
+ const img = document.getElementById(this.#positionToElementId('image', add.position));
+ if (img != null) {
+ img.style.opacity = '0';
+ img.style.transition = transitionStr;
+ addedImages.push(img);
+ requestAnimationFrame(() => {
+ img.style.opacity = '1';
+ });
+ }
+ });
+
+ setTimeout(() => {
+ // drop transient animation images
+ cleanups.forEach(c => c());
+
+ // draw the final image at each move's target square
+ animatedMoves.forEach(mv => {
+ const existing = document.getElementById(this.#positionToElementId('image', mv.to));
+ if (existing != null) existing.remove();
+ this.#drawPieceAt(mv.pieceChar, mv.to);
+ });
+
+ // clear transient styles on faded-in images
+ addedImages.forEach(img => {
+ img.style.transition = '';
+ img.style.opacity = '';
+ });
+
+ onDone();
+ this.#forceSafariLayoutRefresh();
+ }, durationMs + 20);
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param animate {boolean}
+ */
+ registerMoveIfLegal(move, animate = true) {
+ if (!this.#board.isLegalMove(move)) {
+ console.log(move + ' is not a legal move');
+ return;
+ }
+
+ const afterCommit = () => {
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ for (let i = 0; i < this.#afterMoveListeners.length; i++) {
+ this.#afterMoveListeners[i](move);
+ }
+ };
+
+ if (animate) {
+ // the diff-based animator will commit the model via its onDone
+ this.#animateMoveViaDiff(move, afterCommit);
+ } else {
+ const piece = this.#board.getPieceAt(move.from);
+ this.#board.registerMove(move);
+ this.#drawMove(piece.pieceChar, move);
+ afterCommit();
+ }
+ }
+
+ /**
+ * Animate a single move by delegating to the generic diff-based animator
+ * {@link #animatePiecesTo}. The target position is computed on a copy of
+ * the current board so the real model is only mutated once the animation
+ * completes. This replaces the old xiangqi-shape-aware setInterval based
+ * {@code #animateMove}.
+ *
+ * @param move {HalfMove}
+ * @param onDone {function}
+ */
+ #animateMoveViaDiff(move, onDone) {
+ const targetBoard = this.#board.copy();
+ targetBoard.registerMove(move);
+ const targetPieces = targetBoard.listPiecePositions();
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ this.#board.registerMove(move);
+ onDone();
+ });
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ this.#board.addPieceAt(pieceChar, position, enforcePlacementRules);
+ this.#unDrawPiece(position);
+ this.#drawPieceAt(pieceChar, position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#board.removePieceFrom(position);
+ this.#unDrawPiece(position);
+ }
+
+ // FIXME: feels like this could be private? encapsulated?
+ updateHighlightedChecks() {
+ this.#hideAllCheckHighlights();
+
+ if (!this.#options.mini || this.#options.forceRenderChecks) {
+ const redGeneral = this.#board.findGeneral(Color.RED);
+ const blackGeneral = this.#board.findGeneral(Color.BLACK);
+
+ // generals may be absent (e.g. when flip() is called before any
+ // FEN has been loaded, on an empty board); nothing to highlight.
+ if (redGeneral == null || blackGeneral == null) {
+ return;
+ }
+
+ if (this.#board.isInCheck(Color.RED)) {
+ if (this.#board.isCheckmate(Color.RED)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(redGeneral.position);
+ } else {
+ this.#highlightCheck(redGeneral.position);
+ }
+ }
+
+ if (this.#board.isInCheck(Color.BLACK)) {
+ if (this.#board.isCheckmate(Color.BLACK)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(blackGeneral.position);
+ } else {
+ this.#highlightCheck(blackGeneral.position);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ */
+ highlightLastMove(move) {
+ this.#highlightWithClass(move.from, 'highlighted-last-move');
+ this.#highlightWithClass(move.to, 'highlighted-last-move');
+ }
+
+ #hideAllCheckHighlights() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-check', 'highlighted-checkmate'));
+ }
+
+ #hideHighlightedLastMove() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-last-move'));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheck(position) {
+ this.#highlightWithClass(position, 'highlighted-check');
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheckMate(position) {
+ this.#highlightWithClass(position, 'highlighted-checkmate');
+ }
+
+ /**
+ * @param position {Position}
+ * @param className {string}
+ */
+ #highlightWithClass(position, className) {
+ this.#locateLegalMovePlaceHolderAt(position).classList.add(className);
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ isInPlaceHolderMode() {
+ return this.#boardContainer.classList.contains('board-container-placeholder');
+ }
+
+ enablePlaceholderMode() {
+ this.#boardContainer.classList.add('board-container-placeholder');
+ }
+
+ disablePlaceholderMode() {
+ this.#boardContainer.classList.remove('board-container-placeholder');
+ }
+
+ /**
+ * Clear the SVG layer, which atm only render analytics arrows.
+ */
+ clearSvg() {
+ if (this.#options.svg) {
+ const svg = document.getElementById('board-svg');
+ svg.innerHTML = '';
+ svg.innerHTML += ARROW_MARKERS;
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ */
+ addEngineArrow(move, type) {
+ if (this.#options.svg) {
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ this.#primaryEngineArrow = move;
+ break;
+ case EngineArrowType.SECONDARY:
+ this.#secondaryEngineArrow = move;
+ break;
+ }
+
+ this.#renderSvg();
+ }
+ }
+
+ #renderSvg() {
+ if (this.#options.svg) {
+ this.clearSvg();
+ const svg = document.getElementById('board-svg');
+ if (this.#primaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#primaryEngineArrow, EngineArrowType.PRIMARY));
+ }
+ if (this.#secondaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#secondaryEngineArrow, EngineArrowType.SECONDARY));
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ * @returns {SVGGeometryElement[]}
+ */
+ #buildMoveArrow(move, type) {
+ function isSmallMove(move) {
+ return (move.isVertical() && Math.abs(move.to.y - move.from.y) <= 1) ||
+ (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1)
+ }
+
+ function isKnightMove(move) {
+ const dx = Math.abs(move.to.x - move.from.x);
+ const dy = Math.abs(move.to.y - move.from.y);
+ return (dx === 1 && dy === 2) || (dx === 2 && dy === 1);
+ }
+
+ const boardBounds = this.#boardContainer.getBoundingClientRect();
+
+ const square1 = document.getElementById(this.#positionToElementId('square', move.from));
+ const square2 = document.getElementById(this.#positionToElementId('square', move.to));
+ const bound1 = square1.getBoundingClientRect();
+ const bound2 = square2.getBoundingClientRect();
+
+ const x1 = (bound1.left + bound1.width / 2) - boardBounds.left;
+ const y1 = (bound1.top + bound1.height / 2) - boardBounds.top;
+ const x2 = (bound2.left + bound2.width / 2) - boardBounds.left;
+ const y2 = (bound2.top + bound2.height / 2) - boardBounds.top;
+
+ // stroke width scales with the square size so the arrow looks consistent across viewports
+ // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered)
+ const strokeWidth = Math.min(16, bound1.width * 0.16);
+
+ let pathD, midpointX, midpointY;
+
+ if (isKnightMove(move)) {
+ // draw an elbowed arrow matching the horse's actual movement:
+ // segment 1: orthogonal (straight line, 1 square) — the horse's "leg"
+ // segment 2: diagonal (1 square) — the horse's diagonal step
+ const sourceReduction = bound1.width * 0.40;
+ // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the
+ // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly
+ // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth.
+ const destReduction = strokeWidth * 1.9;
+ const dxPx = x2 - x1;
+ const dyPx = y2 - y1;
+
+ // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty)
+ function shortenToward(px, py, tx, ty, r) {
+ const dx = tx - px;
+ const dy = ty - py;
+ const d = Math.sqrt(dx * dx + dy * dy);
+ return [px + (dx / d) * r, py + (dy / d) * r];
+ }
+
+ let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY;
+
+ const dyBoardSquares = Math.abs(move.to.y - move.from.y);
+
+ if (dyBoardSquares === 2) {
+ // vertical-dominant: horse moves 2 squares vertically then 1 diagonally
+ // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement
+ elbowX = x1;
+ elbowY = y1 + dyPx / 2;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ } else {
+ // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally
+ // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement
+ elbowX = x1 + dxPx / 2;
+ elbowY = y1;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ }
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = elbowX;
+ midpointY = elbowY;
+ } else {
+ const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
+
+ // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top)
+ let reduction;
+
+ // we can not reduce by as much when the arrow is already very small
+ // otherwise, the direction of the arrow flips and points the wrong way
+ if (isSmallMove(move)) {
+ // we remove 40% a square length
+ reduction = bound1.width * 0.40;
+ } else {
+ // we remove 50% a square length
+ reduction = bound1.width * 0.50;
+ }
+
+ const reducedDist = dist - reduction;
+ const ratio = reducedDist / dist;
+
+ const x1Prime = x2 + ratio * (x1 - x2);
+ const y1Prime = y2 + ratio * (y1 - y2);
+ const x2Prime = x1 + ratio * (x2 - x1);
+ const y2Prime = y1 + ratio * (y2 - y1);
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = (x1Prime + x2Prime) / 2;
+ midpointY = (y1Prime + y2Prime) / 2;
+ }
+
+ const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ arrowPath.setAttribute('d', pathD);
+ arrowPath.style.strokeWidth = `${strokeWidth}px`;
+ arrowPath.style.fill = 'none';
+ arrowPath.style.strokeLinejoin = 'round';
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ arrowPath.style.stroke = PRIMARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-primary)';
+ break;
+ case EngineArrowType.SECONDARY:
+ arrowPath.style.stroke = SECONDARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-secondary)';
+ break;
+ }
+
+ // draw number circle
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute('r', (bound1.width * 0.20).toString());
+ circle.setAttribute('cx', midpointX.toString());
+ circle.setAttribute('cy', midpointY.toString());
+ circle.setAttribute('fill', 'white');
+ circle.setAttribute('stroke-width', (bound1.width * 0.04).toString());
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ circle.setAttribute('stroke', PRIMARY_ARROW_COLOR);
+ break;
+ case EngineArrowType.SECONDARY:
+ circle.setAttribute('stroke', SECONDARY_ARROW_COLOR);
+ break;
+ }
+
+ // draw number text
+ const numberLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ numberLabel.setAttribute('x', midpointX.toString());
+ numberLabel.setAttribute('y', midpointY.toString());
+ numberLabel.setAttribute('text-anchor', 'middle');
+ numberLabel.setAttribute('dominant-baseline', 'middle');
+ numberLabel.setAttribute('font-size', (bound1.width * 0.25).toString() + 'px');
+ numberLabel.setAttribute('font-weight', 'bold');
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ numberLabel.setAttribute('fill', PRIMARY_ARROW_COLOR);
+ numberLabel.innerHTML = '1';
+ break;
+ case EngineArrowType.SECONDARY:
+ numberLabel.setAttribute('fill', SECONDARY_ARROW_COLOR);
+ numberLabel.innerHTML = '2';
+ break;
+ }
+
+ return [arrowPath, circle, numberLabel];
+ }
+
+ #handlePieceHolderClickEvent(e, position) {
+ let onlyClickedSquareAndNoImage = true;
+
+ let images = document.getElementsByClassName('piece-image');
+ for (let i = 0; i < images.length; i++) {
+ if (images[i].contains(e.target)) {
+ onlyClickedSquareAndNoImage = false;
+ break;
+ }
+ }
+
+ if (onlyClickedSquareAndNoImage) {
+ this.#clickedOnSquare(position);
+ }
+ }
+
+ #clickedOnPiece(position) {
+ if (this.#isPlayerMoveEnabled) {
+ let board = this.#board;
+ let selected = this.#selectedPiecePosition;
+
+ if (selected == null && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting the piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else if (Position.areEquals(selected, position)) {
+ // if the piece user just clicked on is the same as the one he had selected before,
+ // then user is un-selecting the piece
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containOppositeColors(selected, position)) {
+ // user is capturing the piece he clicked on
+ this.registerMoveIfLegal(new HalfMove(selected, position));
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containSameColors(selected, position) && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting another piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ }
+ }
+ }
+
+ #clickedOnSquare(position) {
+ if (this.#isPlayerMoveEnabled) {
+ if (this.#selectedPiecePosition != null) {
+ this.registerMoveIfLegal(new HalfMove(this.#selectedPiecePosition, position));
+ this.#hideAllPiecePlaceHolders();
+ }
+ }
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param move {HalfMove}
+ */
+ #drawMove(pieceChar, move) {
+ this.#unDrawPiece(move.from);
+ this.#unDrawPiece(move.to);
+ this.#drawPieceAt(pieceChar, move.to);
+ }
+
+ #drawBoard() {
+ /**
+ * @param positionType {string} - one of 'bottom-right', 'bottom-left', 'top-right', 'top-left'
+ * @param drawingPosition {Position}
+ * @param positions {Position[]}
+ * @returns {HTMLDivElement|null}
+ */
+ function drawCrosshairForPositionType(positionType, drawingPosition, positions) {
+ const crosshairToDraw = positions.find(crosshair => Position.areEquals(crosshair, drawingPosition));
+ if (crosshairToDraw != null) {
+ const crosshair = document.createElement('div');
+ crosshair.classList.add('crosshair-square', `crosshair-square-${positionType}`);
+ return crosshair;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param drawingPosition {Position}
+ * @returns {HTMLDivElement[]}
+ */
+ function drawCrosshairs(drawingPosition) {
+ const crosshairTypes = [
+ {type: 'bottom-right', positions: bottomRightCrosshairs},
+ {type: 'bottom-left', positions: bottomLeftCrosshairs},
+ {type: 'top-right', positions: topRightCrosshairs},
+ {type: 'top-left', positions: topLeftCrosshairs}
+ ];
+
+ return crosshairTypes
+ .map(({type, positions}) => drawCrosshairForPositionType(type, drawingPosition, positions))
+ .filter(crosshair => crosshair !== null);
+ }
+
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ // draw rows
+ let row = document.createElement('div');
+ row.className = 'rows';
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let renderPosition;
+ if (this.#flippedRed) {
+ renderPosition = new Position(x, y);
+ } else {
+ renderPosition = new Position(BOARD_WIDTH - x - 1, BOARD_HEIGHT - y - 1);
+ }
+
+ const pieceHolder = document.createElement('div');
+ pieceHolder.id = this.#positionToElementId('square', renderPosition);
+ pieceHolder.className = 'piece-holder';
+ pieceHolder.addEventListener('click', (e) => {
+ this.#handlePieceHolderClickEvent(e, renderPosition)
+ });
+
+ // drag and drop listeners
+ pieceHolder.addEventListener('dragover', (e) => e.preventDefault());
+ pieceHolder.addEventListener('dragenter', (e) => e.preventDefault());
+ pieceHolder.addEventListener('drop', (e) => {
+ this.#handlePieceHolderDropEvent(e, renderPosition);
+ });
+
+ const legalMovePlaceHolder = document.createElement('div');
+ legalMovePlaceHolder.id = this.#positionToElementId('legal_move', renderPosition);
+ pieceHolder.appendChild(legalMovePlaceHolder);
+
+ if (y === 5) {
+ if (x === 0) {
+ const river = buildDivWithClass('large-river');
+
+ if (!this.#options.mini) {
+ const riverOfTheChuContainer = buildDivWithClass('river-of-the-chu');
+ riverOfTheChuContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/river-of-the-chu-brown.png`));
+
+ const borderOfTheHanContainer = buildDivWithClass('border-of-the-han');
+ borderOfTheHanContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/border-of-the-han-brown.png`));
+
+ river.append(
+ riverOfTheChuContainer,
+ borderOfTheHanContainer
+ );
+ }
+
+ pieceHolder.append(river);
+ }
+ } else {
+ // draw row of visible squares
+ if (x < BOARD_WIDTH - 1 && y > 0) {
+ const visibleSquare = buildDivWithClass('visible-square');
+ pieceHolder.appendChild(visibleSquare);
+
+ if ((x === 3 && y === 2) || (x === 4 && y === 1) || (x === 3 && y === 9) || (x === 4 && y === 8)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalDescendingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalDescending;
+ }
+ }
+ if ((x === 3 && y === 1) || (x === 4 && y === 2) || (x === 3 && y === 8) || (x === 4 && y === 9)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalRisingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalRising;
+ }
+ }
+
+ if (x === BOARD_WIDTH - 2) {
+ visibleSquare.classList.add('visible-square-last-file');
+ }
+
+ // draw crosshairs
+ drawCrosshairs(new Position(x, y))
+ .forEach(element => visibleSquare.append(element));
+ }
+ }
+ row.appendChild(pieceHolder);
+ }
+ this.#boardContainer.appendChild(row);
+ }
+
+ // add bottom border to last row of visible squares
+ if (this.#flippedRed) {
+ for (let x = 0; x < BOARD_WIDTH - 1; x++) {
+ const position = new Position(x, 1);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ } else {
+ for (let x = BOARD_WIDTH - 1; x > 0; x--) {
+ const position = new Position(x, BOARD_HEIGHT - 2);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ }
+
+ this.#drawCoordinates();
+
+ // TODO: hacky
+ if (this.#isSafari) {
+ let rows = document.getElementsByClassName('rows');
+ for (let i = 0; i < rows.length; i++) {
+ rows[i].classList.add('safari-rows');
+ }
+
+ document.getElementById(this.#options.elementId).classList.add('safari-board-container');
+ }
+
+ if (this.#options.svg) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.id = 'board-svg';
+ this.#boardContainer.append(svg);
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ // https://stackoverflow.com/questions/9628507/how-can-i-tell-google-translate-to-not-translate-a-section-of-a-website
+ #drawCoordinates() {
+ function buildFileDiv(label, cssClass, visible) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = label;
+ if (visible) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = cssClass;
+ div.appendChild(span);
+ return div;
+ }
+
+ if (this.#options.showCoordinates) {
+ const orientation = this.#options.coordinatesOrientation;
+ const isSettingEnabled = orientation !== null;
+ // when the user has disabled coordinates we still reserve the space (hidden labels),
+ // so we must pick an arbitrary orientation for the (invisible) labels:
+ const isWfxOriented = orientation !== CoordinatesOrientation.UCI;
+
+ // draw top file coordinates
+ if (isWfxOriented) {
+ let topCoordinatesY = BOARD_HEIGHT - 1;
+ if (!this.#flippedRed) {
+ topCoordinatesY = 0;
+ }
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label = '?';
+ if (this.#flippedRed) {
+ label = (x + 1).toString();
+ } else {
+ label = (BOARD_WIDTH - x).toString();
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, topCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+
+ // draw bottom file coordinates
+ let bottomCoordinatesY = 0;
+ if (!this.#flippedRed) {
+ bottomCoordinatesY = BOARD_HEIGHT - 1;
+ }
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label = '?';
+ if (isWfxOriented) {
+ if (this.#flippedRed) {
+ label = (BOARD_WIDTH - x).toString();
+ } else {
+ label = (x + 1).toString();
+ }
+ } else {
+ label = UCI_LETTER[x];
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-bottom', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, bottomCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+
+ // draw right-side row coordinates
+ if (!isWfxOriented) {
+ let rightCoordinatesX = BOARD_WIDTH - 1;
+ if (!this.#flippedRed) {
+ rightCoordinatesX = 0;
+ }
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = (y + 1).toString();
+ if (isSettingEnabled) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = 'rows-coordinates-right';
+ div.appendChild(span);
+ let squareId = this.#positionToElementId('square', new Position(rightCoordinatesX, y));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+ }
+ }
+
+ #drawPieces() {
+ this.#board
+ .listPiecePositions()
+ .forEach(piecePosition => this.#drawPiece(piecePosition));
+ }
+
+ /**
+ * Resolves the URL of the image for a given piece char, using this board's
+ * configured `assetsBaseUrl` and `pieceStyle` options. Exposed so external
+ * widgets that share this board's visual identity (e.g. the position
+ * editor's piece palette) can use the exact same image set.
+ *
+ * @param pieceChar {string}
+ * @return {string}
+ */
+ getPieceImageSource(pieceChar) {
+ const style = this.#options.pieceStyle.toLowerCase();
+ return `${this.#options.assetsBaseUrl}/images/pieces/${style}/${pieceImageNames.get(pieceChar)}`;
+ }
+
+ /**
+ * @param piecePosition {PieceAtPosition}
+ */
+ #drawPiece(piecePosition) {
+ this.#drawPieceAt(piecePosition.piece.pieceChar, piecePosition.position);
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param position {Position}
+ */
+ #drawPieceAt(pieceChar, position) {
+ let square = document.getElementById(this.#positionToElementId('square', position));
+ let img = document.createElement('img');
+ img.id = this.#positionToElementId('image', position);
+ img.className = 'piece-image';
+ img.setAttribute('src', this.getPieceImageSource(pieceChar));
+ img.addEventListener('click', () => this.#clickedOnPiece(position));
+ img.addEventListener('dragstart', (e) => this.#dragStart(e, position));
+ img.addEventListener('dragend', () => this.#dragEnd());
+ square.prepend(img);
+ }
+
+ /**
+ * Forces Safari to recalculate layout and positioning of board elements.
+ *
+ * Safari has known issues with layout calculations, particularly with CSS transforms,
+ * absolute positioning, and element positioning after DOM updates. This method applies
+ * various techniques to force Safari to trigger reflows and recalculate element positions.
+ *
+ * The method uses multiple approaches:
+ * - Hardware acceleration triggers via translateZ(0) transforms
+ * - Forced reflows by accessing offsetHeight
+ * - Position property manipulation on squares and piece images
+ *
+ * Called automatically after board state changes like moves, flips, and piece placement
+ * to ensure visual elements are correctly positioned in Safari.
+ *
+ * @private
+ */
+ #forceSafariLayoutRefresh() {
+ if (this.#isSafari) {
+ setTimeout(() => {
+ const container = this.#boardContainer;
+
+ // force container reflow
+ container.style.transform = 'translateZ(0)';
+ container.offsetHeight;
+ container.style.transform = '';
+
+ // force reflow on all squares
+ container
+ .querySelectorAll('.piece-holder')
+ .forEach(square => {
+ square.style.position = 'relative';
+ square.offsetHeight;
+ square.style.position = '';
+ });
+
+ // force reflow on all piece images
+ container
+ .querySelectorAll('.piece-image')
+ .forEach(piece => {
+ piece.style.transform = 'translateZ(0)';
+ piece.offsetHeight;
+ piece.style.transform = '';
+ });
+ }, 0);
+ }
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param position {Position}
+ */
+ #dragStart(e, position) {
+ console.log('drag start');
+
+ if (this.isPlayerMoveEnabled && this.#board.getColorAt(position) === this.#board.getColorToPlay()) {
+ e.dataTransfer.setData('text/plain', position.toUci());
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else {
+ e.preventDefault();
+ }
+ }
+
+ #dragEnd() {
+ console.log('drag end');
+
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param to {Position}
+ */
+ #handlePieceHolderDropEvent(e, to) {
+ const uci = e.dataTransfer.getData('text/plain');
+ const from = Position.parseUci(uci);
+ const move = new HalfMove(from, to);
+ this.registerMoveIfLegal(move, false);
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ #unDrawAllPieces() {
+ Position.getAll().forEach(position => this.#unDrawPiece(position));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #unDrawPiece(position) {
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ // NB: getElementsByClassName returns a live HTMLCollection; convert to a
+ // static array so removing elements doesn't skip siblings.
+ htmlCollectionToArray(square.getElementsByClassName('piece-image'))
+ .forEach(img => square.removeChild(img));
+ }
+
+ #hideAllPiecePlaceHolders() {
+ Position.getAll().forEach(position => {
+ // in case when the board is a mini board overview on an infinite scroll page
+ // the placeholder can be null if the miniboard has been discarded
+ // which would throw a bunch of errors in the console if we don't check for nullability
+ const placeHolder = this.#locateLegalMovePlaceHolderAt(position);
+ if (placeHolder != null) {
+ placeHolder.classList.remove(
+ 'legal-move-place-holder',
+ 'selected-piece',
+ 'possible-capture',
+ 'highlighted-last-move'
+ );
+ }
+ });
+
+ this.#selectedPiecePosition = null;
+ this.#currentShowingLegalMovesFor = null;
+ }
+
+ #showSelectedPositionAndLegalMovesPlaceHolders(position) {
+ // remove previous placeholders
+ this.#hideAllPiecePlaceHolders();
+
+ // show selected piece
+ this.#locateLegalMovePlaceHolderAt(position).classList.add('selected-piece');
+ this.#selectedPiecePosition = position;
+
+ // show legal moves and possible captures
+ this.#showLegalMovesPlaceHolders(position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #showLegalMovesPlaceHolders(position) {
+ this
+ .#board
+ .listLegalMovesFrom(position)
+ .map(move => move.to)
+ .forEach(targetPosition => {
+ let placeHolder = this.#locateLegalMovePlaceHolderAt(targetPosition);
+ if (this.#board.containOppositeColors(position, targetPosition)) {
+ placeHolder.classList.add('possible-capture');
+ } else {
+ placeHolder.classList.add('legal-move-place-holder');
+ }
+ });
+
+ this.#currentShowingLegalMovesFor = position;
+ }
+
+ highlightDebugMove(move, color) {
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-debug');
+ from.style.backgroundColor = color;
+ to.classList.add('highlighted-debug');
+ to.style.backgroundColor = color;
+ }
+
+ hideAllDebugHighlight() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-debug');
+ element.style.backgroundColor = null;
+ });
+ }
+
+ highlightDynamicMove(move) {
+ this.#hideAllPiecePlaceHolders();
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-move');
+ to.classList.add('highlighted-move');
+ }
+
+ hideAllHighlightedDynamicMoves() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-move');
+ });
+ }
+
+ clearBoard() {
+ this.#unDrawAllPieces();
+ this.#hideAllPiecePlaceHolders();
+ this.#board.clearBoard();
+ }
+
+ /**
+ * @param color {string|null}
+ */
+ flipToColor(color) {
+ if (color != null && ((this.#flippedRed && color === Color.BLACK) || (!this.#flippedRed && color === Color.RED))) {
+ this.flip();
+ }
+ }
+
+ flip() {
+ this.#boardContainer.innerHTML = '';
+ this.#flippedRed = !this.#flippedRed;
+ this.#drawBoard();
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+
+ const newColor = this.#flippedRed ? Color.RED : Color.BLACK;
+ this.#afterFlipListeners.forEach(listener => listener(newColor));
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ *
+ * @param moveFormat {string}
+ */
+ updateMoveFormat(moveFormat) {
+ // easy solution: complete redraw (TODO: can more subtle)
+ this.#boardContainer.innerHTML = '';
+ this.#drawBoard();
+ this.#drawPieces();
+ }
+
+ reRenderPieces() {
+ this.#unDrawAllPieces();
+ this.#drawPieces();
+ }
+
+ /**
+ * Update the piece style and re-render the pieces. Needed because the
+ * options object is otherwise frozen and the piece style can be changed at
+ * runtime by the user (via the settings menu).
+ *
+ * @param pieceStyle {string} one of {@link PieceStyleSetting}
+ */
+ updatePieceStyle(pieceStyle) {
+ if (this.#options.pieceStyle === pieceStyle) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, pieceStyle});
+ this.reRenderPieces();
+ }
+
+ /**
+ * @return {boolean}
+ */
+ toggleShowCoordinates() {
+ let areVisible = this.#areCoordinatesVisible();
+ this.#renderShowCoordinatesSetting(!areVisible);
+ return !areVisible;
+ }
+
+ /**
+ * @param show {boolean}
+ */
+ #renderShowCoordinatesSetting(show) {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ label.style.visibility = show ? 'visible' : 'hidden';
+ }
+ }
+
+ #areCoordinatesVisible() {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ if (label.style.visibility === 'hidden') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ #hideAllDraggableCursors() {
+ this.#board
+ .listPiecePositions()
+ .forEach(pieceAtPosition => {
+ const imageId = this.#positionToElementId('image', pieceAtPosition.position);
+ const image = document.getElementById(imageId);
+ // image may be momentarily absent while an animation is in
+ // flight (source-square images are renamed to 'animating-xxx'
+ // until the animation commits). In that case there's nothing
+ // to update here; the animation's onDone will re-run cursor
+ // bookkeeping via #resetDraggableCursors().
+ if (image != null) {
+ image.classList.remove('piece-image-can-move');
+ }
+ });
+ }
+
+ #resetDraggableCursors() {
+ const isMated = () => {
+ try {
+ return this.#board.isMated();
+ } catch (e) {
+ // FIXME: happens when re-loading a game where it's your turn to play and enablePlayerMove is called
+ console.warn('error while checking if mated');
+ return false;
+ }
+ }
+
+ this.#hideAllDraggableCursors();
+
+ if (this.isPlayerMoveEnabled && !isMated()) {
+ const colorToPlay = this.#board.getColorToPlay();
+
+ this.#board
+ .listPiecePositions()
+ .filter(piecePosition => colorToPlay === piecePosition.pieceColor)
+ .map(piecePosition => piecePosition.position)
+ .filter(position => this.#board.listLegalMovesFrom(position).length > 0)
+ .forEach(position => {
+ const imageId = this.#positionToElementId('image', position);
+ const image = document.getElementById(imageId);
+ // see #hideAllDraggableCursors: image may be absent
+ // mid-animation (ID temporarily renamed).
+ if (image != null) {
+ image.classList.add('piece-image-can-move');
+ }
+ });
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {HTMLElement}
+ */
+ #locateLegalMovePlaceHolderAt(position) {
+ return document.getElementById(this.#positionToElementId('legal_move', position));
+ }
+
+ /**
+ * @param position {Position}
+ * @param prefix {string}
+ * @returns {string}
+ */
+ #positionToElementId(prefix, position) {
+ return boardPositionToElementId(this.#options.elementId, prefix.toLowerCase(), position);
+ }
+
+}
+
+/**
+ *
+ * @param boardId {string}
+ * @param prefix {string}
+ * @param position {Position}
+ */
+function boardPositionToElementId(boardId, prefix, position) {
+ return `${boardId}-${prefix.toLowerCase()}-${position.x}-${position.y}`;
+}
+
+/**
+ * @param elementId {string}
+ * @returns {Position}
+ */
+function parsePositionFromElementId(elementId) {
+ const split = elementId.split('-');
+ const x = Number(split[split.length - 2]);
+ const y = Number(split[split.length - 1]);
+ return new Position(x, y);
+}
+
+/**
+ * Adds a miniature board that appears on hover for an element
+ *
+ * @param element {HTMLElement} - The element to attach hover listeners to
+ * @param gameId {string} - Unique identifier for this miniboard
+ * @param fen {string} - FEN string representing the board position
+ * @param playerColor {string} - Color to flip the board to
+ * @param lazy {boolean} - If true, only create the board on first mouseenter (default: false)
+ * @returns {HTMLElement|null} - The created miniboard div (null if lazy and not yet created)
+ */
+function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) {
+ const LEFT_MARGIN = 12;
+ const MINI_BOARD_HEIGHT = 256 / 0.9;
+
+ const miniBoardId = `mini-board-overview-${gameId}`;
+ let miniBoardDiv = null;
+
+ function createBoard() {
+ if (miniBoardDiv) return;
+
+ miniBoardDiv = document.createElement('div');
+ miniBoardDiv.id = miniBoardId;
+ miniBoardDiv.classList.add(
+ 'board-container',
+ 'mini-board-container',
+ 'mini-board-overview'
+ );
+
+ document.body.appendChild(miniBoardDiv);
+
+ const options = {
+ elementId: miniBoardId,
+ showCoordinates: false,
+ mini: true,
+ forceRenderChecks: true,
+ };
+
+ const boardGui = new BoardGui(options);
+ boardGui.loadFen(fen);
+ boardGui.flipToColor(playerColor);
+ boardGui.updateHighlightedChecks();
+ }
+
+ // Create board immediately if not lazy
+ if (!lazy) {
+ createBoard();
+ }
+
+ // listeners
+ function showMiniboard() {
+ // Create board on first hover if lazy
+ if (!miniBoardDiv) {
+ createBoard();
+ }
+
+ const gameItemRect = element.getBoundingClientRect();
+ const left = gameItemRect.right + LEFT_MARGIN + window.scrollX;
+ const top = gameItemRect.top + window.scrollY + (gameItemRect.height / 2) - (MINI_BOARD_HEIGHT / 2);
+ miniBoardDiv.style.top = `${top}px`;
+ miniBoardDiv.style.left = `${left}px`;
+ miniBoardDiv.style.display = 'block';
+ }
+
+ function hideMiniboard() {
+ if (miniBoardDiv) {
+ miniBoardDiv.style.display = 'none';
+ }
+ }
+
+ element.addEventListener('mouseenter', showMiniboard);
+ element.addEventListener('mouseleave', hideMiniboard);
+
+ return miniBoardDiv;
+}
diff --git a/webapp/src/main/resources/public/dist/0.0.3/board.css b/webapp/src/main/resources/public/dist/0.0.3/board.css
new file mode 100644
index 000000000..08a524a80
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.3/board.css
@@ -0,0 +1,414 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+:root {
+ --crosshair-margin: 5%;
+}
+
+#board-svg {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ z-index: 110;
+ pointer-events: none;
+}
+
+.board-container {
+ margin: auto;
+ aspect-ratio: 0.9;
+ background-color: #876e59;
+ padding: 4%;
+ text-align: initial;
+ touch-action: manipulation;
+ position: relative;
+ /* Matches the shared .basic-box / .info-box rule in style.css so board.css
+ renders identically when used standalone (as a library). */
+ box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em;
+ border: 2px solid #323232;
+ border-radius: 3px;
+}
+
+.safari-board-container {
+ display: flex;
+ display: -webkit-box;
+}
+
+.board-outer-container {
+ position: relative;
+ width: 80%;
+}
+
+.board-container-placeholder {
+ background-color: #808080;
+}
+
+.board-container-placeholder .visible-square,
+.board-container-placeholder .large-river {
+ background-color: #dbd8d8;
+}
+
+.board-container-placeholder .piece-image {
+ filter: grayscale(90%);
+}
+
+.board-container-placeholder .coordinates-labels {
+ color: #dbd8d8;
+}
+
+a.board-outer-container-link-mask {
+ position: absolute;
+ aspect-ratio: 0.9;
+ z-index: 150;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ width: 100%;
+}
+
+.rows {
+ height: 10%;
+}
+
+/* TODO: could be replaced by '.safari-board .rows' */
+.safari-rows {
+ height: 9%;
+ display: inline-block;
+ vertical-align: top;
+}
+
+.piece-holder {
+ float: left;
+ position: relative;
+ height: 100%;
+ aspect-ratio: 1;
+}
+
+.piece-holder .piece-image {
+ width: 95%;
+ height: 95%;
+ margin: 2.5%;
+ position: absolute;
+ border-radius: 100%;
+ box-shadow: rgba(0, 0, 0, 0.24) 0px 2px 5px;
+ z-index: 100;
+}
+
+.piece-holder .piece-image-can-move {
+ cursor: move;
+}
+
+.piece-holder .piece-image-moving {
+ z-index: 110;
+}
+
+.crosshair-square {
+ position: absolute;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ /*background-color: rgba(0, 0, 227, 0.47);*/
+ width: 19%;
+ height: 19%;
+ z-index: 80;
+ box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.crosshair-square-bottom-right {
+ bottom: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-top-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-bottom-left {
+ bottom: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-top-width: 0;
+ border-right-width: 0;
+}
+
+.crosshair-square-top-right {
+ top: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-top-left {
+ top: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+/* 2px because it's the border size of visible-square */
+.safari-board-container .visible-square .crosshair-square-bottom-right,
+.safari-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 2px);
+}
+
+.safari-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+/* 1px because it's the border size of visible-square */
+.safari-mini-board-container .visible-square .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 1px);
+}
+
+.safari-mini-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+.visible-square, .large-river {
+ background-color: #ead1af;
+ height: 100%;
+ margin: 50%;
+ aspect-ratio: 1;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ border-right-width: 0;
+ border-bottom-width: 0;
+ position: absolute;
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.large-river {
+ aspect-ratio: 8;
+ height: 100%;
+}
+
+.visible-square-last-file,
+.large-river {
+ border-right-width: 2px;
+}
+
+.visible-square-last-row {
+ border-bottom-width: 2px;
+}
+
+.river-of-the-chu,
+.border-of-the-han {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 48%;
+}
+
+.river-of-the-chu {
+ float: left;
+}
+
+.border-of-the-han {
+ float: right;
+}
+
+.river-of-the-chu img,
+.border-of-the-han img {
+ opacity: 60%;
+ height: 70%;
+}
+
+.mini-board-container .piece-image {
+ box-shadow: none;
+}
+
+.mini-board-container .visible-square,
+.mini-board-container .large-river {
+ border-width: 1px;
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+.mini-board-container .visible-square-last-row {
+ border-bottom-width: 1px;
+}
+
+.mini-board-container .visible-square-last-file,
+.mini-board-container .large-river {
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-right {
+ border-bottom-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-left {
+ border-bottom-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-right {
+ border-top-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-left {
+ border-top-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-overview {
+ display: none;
+ position: absolute;
+ z-index: 700;
+ max-width: 236px;
+ width: 236px;
+ padding: 8px;
+}
+
+.safari-mini-board-container .safari-rows {
+ height: 10%;
+}
+
+.safari-mini-board-container {
+ height: 306px;
+ width: 276px;
+}
+
+.file-coordinates-top,
+.file-coordinates-bottom,
+.rows-coordinates-right {
+ font-size: 12px;
+ color: #ead1af;
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.file-coordinates-bottom {
+ margin-top: 100%;
+ margin-left: 50%;
+}
+
+.file-coordinates-top {
+ margin-top: -25%;
+ margin-left: 40%;
+}
+
+.rows-coordinates-right {
+ margin-left: 65%;
+ width: 100%;
+ height: 100%;
+}
+
+.legal-move-place-holder,
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-move,
+.highlighted-check,
+.highlighted-checkmate,
+.highlighted-last-move {
+ position: absolute;
+ border-radius: 100%;
+ z-index: 10;
+}
+
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-check {
+ width: 110%;
+ height: 110%;
+ margin: -5%;
+}
+
+.highlighted-checkmate,
+.highlighted-move {
+ width: 120%;
+ height: 120%;
+ margin: -10%;
+}
+
+.legal-move-place-holder {
+ width: 75%;
+ height: 75%;
+ margin: 12.5%;
+}
+
+.highlighted-last-move {
+ width: 115%;
+ height: 115%;
+ margin: -7.5%;
+}
+
+.legal-move-place-holder {
+ background-color: rgba(0, 64, 255, 0.4);
+}
+
+.selected-piece {
+ background-color: rgba(40, 229, 27, 0.7);
+}
+
+.possible-capture {
+ background-color: blueviolet;
+}
+
+.highlighted-check {
+ background-color: orange;
+}
+
+.highlighted-checkmate {
+ background-color: red;
+}
+
+.highlighted-move {
+ background-color: rgba(247, 247, 17, 0.79);
+}
+
+.highlighted-last-move {
+ border: 5px dashed rgba(13, 98, 7, 0.7);
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+/* reactive: hide river/han decorations on narrower viewports */
+@media (max-width: 1400px) {
+ .river-of-the-chu,
+ .border-of-the-han {
+ display: none;
+ }
+}
+
+/* reactive: enlarge board on small viewports */
+@media (max-width: 1000px) {
+ .board-outer-container-link-mask,
+ .board-outer-container {
+ width: 90%;
+ }
+
+ /* x2 compared to normal */
+ .safari-mini-board-container {
+ height: 612px;
+ width: 552px;
+ }
+}
diff --git a/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js b/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js
new file mode 100644
index 000000000..ce9ab435b
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.3/xiangqi.js
@@ -0,0 +1,1405 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see .
+ */
+
+const BOARD_WIDTH = 9;
+const BOARD_HEIGHT = 10;
+const UCI_LETTER = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
+const PIECES_CHARS = ['c', 'r', 'n', 'b', 'a', 'k', 'p'];
+
+const DEFAULT_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 0';
+
+// Piece color enum. Lives here (rather than in enums.js) so that xiangqi.js
+// can be used standalone (together with board-gui.js) without pulling in the
+// rest of the webapp's modules.
+const Color = Object.freeze({
+ RED: 'RED',
+ BLACK: 'BLACK'
+});
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isRedPiece(char) {
+ return char.length === 1 && char.toUpperCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isBlackPiece(char) {
+ return char.length === 1 && char.toLowerCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {string}
+ */
+function charToPieceColor(char) {
+ if (char.length !== 1) {
+ throw new Error('Not a single char: ' + char);
+ } else {
+ if (isRedPiece(char)) {
+ return Color.RED;
+ } else if (isBlackPiece(char)) {
+ return Color.BLACK;
+ } else {
+ throw new Error('Illegal piece ' + char);
+ }
+ }
+}
+
+function reverseColor(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return Color.BLACK;
+ case Color.BLACK:
+ return Color.RED;
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+function colorToUci(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return 'w';
+ case Color.BLACK:
+ return 'b';
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+/**
+ * Reset full count to 0
+ */
+function resetFenFullMovesCount(fen) {
+ let split = fen.split(' ');
+ return split.slice(0, split.length - 1).join(' ') + ' 0';
+}
+
+function validateStartFen(fen) {
+ let board = new Board();
+ board.loadFen(fen);
+
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ throw new Error('Start position is checkmate');
+ } else if (board.isStalemate(Color.RED) || board.isStalemate(Color.BLACK)) {
+ throw new Error('Start position is stalemate');
+ }
+}
+
+/**
+ * Add full moves count and convert to single string
+ * @param {string[]} movesAsPgn
+ */
+function toSingleLinePgn(movesAsPgn) {
+ let pgn = '';
+ for (let i = 0; i < movesAsPgn.length; i++) {
+ let move = movesAsPgn[i];
+ if (i % 2 === 0) {
+ pgn += (Math.floor(i / 2) + 1) + '. ';
+ }
+ pgn += move + ' ';
+ }
+ return pgn;
+}
+
+/**
+ * https://en.wikipedia.org/wiki/Xiangqi#System_3
+ *
+ * @param {HalfMove[]} moves
+ * @param {boolean} renderCheckIndicators
+ * @param {string} startFen
+ * @return {string[]}
+ */
+function translateMovesToPgn(moves, renderCheckIndicators = true, startFen = DEFAULT_START_FEN) {
+ let movesAsPgn = [];
+
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let pieceFrom = board.getPieceAt(move.from);
+ let pieceTarget = board.getPieceAt(move.to);
+ let newPosition = move.to.toAlgebraic();
+
+ let capture = '';
+ if (pieceTarget != null) {
+ capture = 'x';
+ }
+ let pieceLetter = pieceFrom.pieceChar.toUpperCase();
+ let formerPosition = UCI_LETTER[move.from.x];
+ let sameFile = move.from.x === move.to.x;
+ if (pieceLetter === 'K' || sameFile) {
+ formerPosition = '';
+ }
+
+ board.registerMove(move);
+
+ let checkIndicator = '';
+ if (renderCheckIndicators) {
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ checkIndicator += '#';
+ } else if (board.isInCheck(Color.RED) || board.isInCheck(Color.BLACK)) {
+ checkIndicator += '+';
+ }
+ }
+
+ let moveString = `${pieceLetter}${formerPosition}${capture}${newPosition}${checkIndicator}`;
+ movesAsPgn.push(moveString);
+ });
+
+ return movesAsPgn;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToPrefixedAlgebraic(moves, startFen = DEFAULT_START_FEN) {
+ const formattedMoves = [];
+ const board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ const pieceChar = board.getPieceAt(move.from).pieceChar.toUpperCase();
+ const moveString = `${pieceChar} ${move.toAlgebraic()}`;
+ board.registerMove(move);
+ formattedMoves.push(moveString);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param horizontalSeparator {string} can be '=' or '.'
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToWxf(moves, horizontalSeparator, startFen) {
+
+ /**
+ * File number in WXF format starts at 1 from the right hand of the player
+ *
+ * @return {number}
+ */
+ function fileAsWxf(color, file) {
+ switch (color) {
+ case Color.RED:
+ return BOARD_WIDTH - file;
+ case Color.BLACK:
+ return file + 1;
+ default:
+ throw new Error('Illegal color ' + color);
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -)
+ */
+ function verticalMoveDirectionChar(color, move) {
+ if ((color === Color.BLACK && move.from.y > move.to.y) || (color === Color.RED && move.from.y < move.to.y)) {
+ return '+';
+ } else {
+ return '-';
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -) followed by distance
+ */
+ function verticalMove(color, move) {
+ let direction = verticalMoveDirectionChar(color, move);
+ let distance = Math.abs(move.from.y - move.to.y);
+ return `${direction}${distance}`;
+ }
+
+ /**
+ * File number or sign (+ or -)
+ *
+ * @param board {Board}
+ * @param move {HalfMove}
+ * @return {string}
+ */
+ function fileNumberOrSign(board, move) {
+ let physicalPiece = board.getPieceAt(move.from);
+ let color = physicalPiece.color;
+
+ let allSimilarPiecesOnFile = board.listPiecePositions().filter(pieceAtPosition => {
+ return pieceAtPosition.piece.pieceChar === physicalPiece.pieceChar && pieceAtPosition.position.x === move.from.x
+ });
+
+ if (allSimilarPiecesOnFile.length === 2) {
+ let currentFileY;
+ let otherFileY;
+ if (move.from.y === allSimilarPiecesOnFile[0].position.y) {
+ currentFileY = allSimilarPiecesOnFile[0].position.y;
+ otherFileY = allSimilarPiecesOnFile[1].position.y;
+ } else {
+ currentFileY = allSimilarPiecesOnFile[1].position.y;
+ otherFileY = allSimilarPiecesOnFile[0].position.y;
+ }
+
+ let isOnTop = (currentFileY > otherFileY && color === Color.RED)
+ || (currentFileY < otherFileY && color === Color.BLACK);
+
+ return isOnTop ? '+' : '-';
+ }
+
+ return fileAsWxf(color, move.from.x).toString();
+ }
+
+ let formattedMoves = [];
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let physicalPiece = board.getPieceAt(move.from);
+ if (physicalPiece == null) {
+ board.printBoard();
+ throw new Error(`no piece at ${move.from.toAlgebraic()}`);
+ }
+ let color = physicalPiece.color;
+ let pieceType = physicalPiece.pieceChar.toUpperCase();
+
+ let moveStr = '';
+ switch (pieceType) {
+ case 'P':
+ case 'C':
+ case 'R':
+ case 'K':
+ if (move.isHorizontal()) {
+ let fileStr = fileNumberOrSign(board, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${fileStr}${horizontalSeparator}${newFile}`;
+ } else {
+ moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`;
+ }
+ break;
+ case 'B':
+ case 'A':
+ case 'N':
+ let currentFileStr = fileNumberOrSign(board, move);
+ let direction = verticalMoveDirectionChar(color, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${currentFileStr}${direction}${newFile}`;
+ break;
+ }
+
+ let pieceCharacter;
+
+ switch (pieceType) {
+ case 'N':
+ pieceCharacter = 'H';
+ break;
+ case 'B':
+ pieceCharacter = 'E';
+ break;
+ default:
+ pieceCharacter = pieceType;
+ break;
+ }
+
+ formattedMoves.push(`${pieceCharacter}${moveStr}`);
+ board.registerMove(move);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ *
+ * Generic version of the functions above
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesFormat(moves, moveFormat, startFen) {
+ switch (moveFormat) {
+ case MoveFormatSetting.WXF_DOT:
+ return translateMovesToWxf(moves, '.', startFen);
+ case MoveFormatSetting.WXF_EQUALS:
+ return translateMovesToWxf(moves, '=', startFen);
+ case MoveFormatSetting.PGN:
+ return translateMovesToPgn(moves, false, startFen);
+ case MoveFormatSetting.ALGEBRAIC_EN:
+ return translateMovesToPrefixedAlgebraic(moves, startFen);
+ default:
+ throw new Error('Unsupported move format ' + moveFormat);
+ }
+}
+
+/**
+ *
+ * Mainly for debugging purposes
+ *
+ * @param allMoves {HalfMove[]}
+ * @param format {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function safeTranslateMovesFormat(allMoves, format, startFen) {
+ try {
+ return translateMovesFormat(allMoves, format, startFen);
+ } catch (e) {
+ console.error(e);
+ console.info(`startFen ${startFen}`);
+ console.info(`moves [${allMoves.length}] ${allMoves.map(move => move.toAlgebraic())}`);
+ return allMoves.map(move => move.toAlgebraic());
+ }
+}
+
+/**
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {null|string}
+ */
+function translateMovesFormatTakeLast(moves, moveFormat, startFen) {
+ let translated = translateMovesFormat(moves, moveFormat, startFen);
+ if (translated.length > 0) {
+ return translated[translated.length - 1];
+ } else {
+ return null;
+ }
+}
+
+/**
+ * @param {HalfMove[]} moves
+ * @return {string}
+ */
+function exportMovesToPgnLine(moves) {
+ return toSingleLinePgn(translateMovesToPgn(moves));
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string}
+ */
+function calculateFen(moves, startFen = DEFAULT_START_FEN) {
+ const board = new Board();
+ board.loadFen(startFen);
+ moves.forEach(move => board.registerMove(move));
+ return board.outputFen();
+}
+
+class Position {
+
+ static redGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), 0);
+ static blackGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), BOARD_HEIGHT - 1);
+
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ get file() {
+ return UCI_LETTER[this.x];
+ }
+
+ /**
+ * @return {number}
+ */
+ get rank() {
+ return this.y;
+ }
+
+ existsOnBoard() {
+ return this.x >= 0 &&
+ this.y >= 0 &&
+ this.x < BOARD_WIDTH &&
+ this.y < BOARD_HEIGHT;
+ }
+
+ isInRedPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y <= 2;
+ }
+
+ isInBlackPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y >= BOARD_HEIGHT - 3;
+ }
+
+ getTop() {
+ return new Position(this.x, this.y + 1);
+ }
+
+ getBottom() {
+ return new Position(this.x, this.y - 1);
+ }
+
+ getLeft() {
+ return new Position(this.x - 1, this.y);
+ }
+
+ getRight() {
+ return new Position(this.x + 1, this.y);
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllTop() {
+ let squares = [];
+ for (let y = this.y + 1; y < BOARD_HEIGHT; y++) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllBottom() {
+ let squares = [];
+ for (let y = this.y - 1; y >= 0; y--) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllLeft() {
+ let squares = [];
+ for (let x = this.x - 1; x >= 0; x--) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllRight() {
+ let squares = [];
+ for (let x = this.x + 1; x < BOARD_WIDTH; x++) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ getTopLeft() {
+ return new Position(this.x - 1, this.y + 1);
+ }
+
+ getTopRight() {
+ return new Position(this.x + 1, this.y + 1);
+ }
+
+ getBottomLeft() {
+ return new Position(this.x - 1, this.y - 1);
+ }
+
+ getBottomRight() {
+ return new Position(this.x + 1, this.y - 1);
+ }
+
+ isEqualsTo(other) {
+ return this.x === other.x && this.y === other.y;
+ }
+
+ /**
+ * 0-based
+ */
+ toUci() {
+ return this.file + this.y;
+ }
+
+ /**
+ * 1-based
+ */
+ toAlgebraic() {
+ return this.file + (this.y + 1);
+ }
+
+ toString() {
+ return this.toUci();
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ static getAll() {
+ let positions = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ positions.push(new Position(x, y));
+ }
+ }
+ return positions;
+ }
+
+ static areEquals(p1, p2) {
+ return p1 != null && p2 != null && p1.isEqualsTo(p2);
+ }
+
+ /**
+ * @param uci {string}
+ * @return {Position}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 2) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ let x = UCI_LETTER.indexOf(uci[0])
+ if (x < 0) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ if (x >= BOARD_WIDTH) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ return new Position(x, Number(uci[1]));
+ }
+
+}
+
+class PhysicalPiece {
+
+ #pieceChar;
+ #initPosition;
+
+ /**
+ * @param pieceChar {string}
+ * @param initPosition {Position}
+ */
+ constructor(pieceChar, initPosition) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid piece ' + pieceChar);
+ }
+
+ this.#pieceChar = pieceChar;
+ this.#initPosition = initPosition;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceChar() {
+ return this.#pieceChar;
+ }
+
+ get color() {
+ return charToPieceColor(this.#pieceChar);
+ }
+
+ /**
+ * @param other {PhysicalPiece}
+ * @return {boolean}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.#pieceChar === other.#pieceChar &&
+ this.#initPosition.isEqualsTo(other.#initPosition);
+ }
+
+ toString() {
+ return `${this.#pieceChar}{${this.#initPosition.toAlgebraic()}}`;
+ }
+
+}
+
+class PieceAtPosition {
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ constructor(piece, position) {
+ this.piece = piece;
+ this.position = position;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceColor() {
+ return this.piece.color;
+ }
+
+ isColor(color) {
+ return this.pieceColor === color;
+ }
+
+ /**
+ * @param other {PieceAtPosition}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.piece.pieceChar === other.piece.pieceChar &&
+ this.position.isEqualsTo(other.position);
+ }
+
+ toString() {
+ return this.piece.pieceChar + ' at ' + this.position.toAlgebraic();
+ }
+
+}
+
+class HalfMove {
+
+ /**
+ * @type {Position}
+ */
+ from;
+
+ /**
+ * @type {Position}
+ */
+ to;
+
+ constructor(from, to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ toUci() {
+ return this.from.toUci() + this.to.toUci();
+ }
+
+ toAlgebraic() {
+ return this.from.toAlgebraic() + this.to.toAlgebraic();
+ }
+
+ isHorizontal() {
+ return this.from.y === this.to.y;
+ }
+
+ isVertical() {
+ return this.from.x === this.to.x;
+ }
+
+ toString() {
+ return this.toAlgebraic();
+ }
+
+ /**
+ * @param uci {string}
+ * @return {HalfMove}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 4) {
+ throw new Error('Incorrect move UCI: ' + uci);
+ }
+ let from = Position.parseUci(uci.substring(0, 2));
+ let to = Position.parseUci(uci.substring(2, 4));
+ return new HalfMove(from, to);
+ }
+
+ /**
+ * @param movesAsUci {string[]}
+ */
+ static parseUciMultipleMoves(movesAsUci) {
+ return movesAsUci.map(moveAsUci => HalfMove.parseUci(moveAsUci));
+ }
+
+ static areEquals(m1, m2) {
+ return m1 != null && m2 != null && Position.areEquals(m1.from, m2.from) && Position.areEquals(m1.to, m2.to);
+ }
+
+ static uciToAlgebraic(uci) {
+ return HalfMove.parseUci(uci).toAlgebraic();
+ }
+
+}
+
+class Board {
+
+ #content = [];
+ #redToPlay = true;
+ #enforceColorTurn = true;
+ #fullMovesCounts = 0;
+
+ constructor() {
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ this.#content.push([null]);
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ this.#content[x][y] = null;
+ }
+ }
+ }
+
+ // TODO: update "full moves count" accordingly
+ loadFen(fen) {
+ const split = fen.split(' ');
+ const positionsFen = split[0];
+ const gameStateFen = split[1];
+ const fenLines = positionsFen.trim().split("/")
+ if (fenLines.length !== BOARD_HEIGHT) {
+ throw new Error('Invalid FEN: wrong number of component');
+ }
+ this.clearBoard();
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ this.#loadFenLine(fenLines[BOARD_HEIGHT - 1 - y], y);
+ }
+
+ switch (gameStateFen.toLowerCase().trim()[0]) {
+ case 'w':
+ case 'r':
+ this.#redToPlay = true;
+ break;
+ case 'b':
+ this.#redToPlay = false;
+ break;
+ default:
+ throw new Error('Invalid FEN: can not determine which side plays next');
+ }
+ }
+
+ /**
+ * @param fenLine {string}
+ * @param y {number}
+ */
+ #loadFenLine(fenLine, y) {
+ let x = 0
+ for (let c = 0; fenLine.length - 1 && x < BOARD_WIDTH; c++) {
+ const char = fenLine.charAt(c);
+ if (char >= '0' && char <= '9') {
+ x += Number(char);
+ } else {
+ this.#content[x][y] = new PhysicalPiece(char, new Position(x, y));
+ x++;
+ }
+ }
+ }
+
+ outputFen() {
+ let ranks = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ ranks.push(this.#rankToFen(y));
+ }
+ return ranks.join('/') + ' ' + colorToUci(this.getColorToPlay()) + ' - - 0 ' + this.#fullMovesCounts.toString();
+ }
+
+ #rankToFen(rank) {
+ let rankPieces = Position
+ .getAll()
+ .filter(position => position.y === rank)
+ .map(position => this.getPieceAt(position));
+
+ let count = 0
+ let result = '';
+ rankPieces.forEach(piece => {
+ if (piece == null) {
+ count += 1;
+ } else {
+ if (count > 0) {
+ result += count.toString();
+ count = 0;
+ }
+ result = result.concat(piece.pieceChar);
+ }
+ });
+ if (count > 0) {
+ result += count.toString();
+ }
+
+ return result;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {PhysicalPiece|null}
+ */
+ getPieceAt(position) {
+ return this.#content[position.x][position.y];
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#content[position.x][position.y] = null;
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid char: ' + pieceChar);
+ }
+
+ // TODO: enforce rules (elephant, king, etc.)
+
+ const physicalPiece = new PhysicalPiece(pieceChar, position);
+ this.#setPieceAt(physicalPiece, position);
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ #setPieceAt(piece, position) {
+ this.#content[position.x][position.y] = piece;
+ }
+
+ /**
+ * @return {PieceAtPosition[]}
+ */
+ listPiecePositions() {
+ return Position
+ .getAll()
+ .filter(position => this.#hasPieceAt(position))
+ .map(position => new PieceAtPosition(this.getPieceAt(position), position));
+ }
+
+ /**
+ * Find all positions where a piece of type {@param piece} is located
+ * (i.e. max 5 for pawns, max 2 for knights, etc.)
+ *
+ * @param pieceChar {string}
+ * @return {Position[]}
+ */
+ listPositionsForPiece(pieceChar) {
+ let result = [];
+ Position.getAll().forEach(position => {
+ let physicalPiece = this.getPieceAt(position);
+ if (physicalPiece != null && physicalPiece.pieceChar === pieceChar) {
+ result.push(position);
+ }
+ });
+ return result;
+ }
+
+ clearBoard() {
+ this.#fullMovesCounts = 0;
+ Position.getAll().forEach(position => this.removePieceFrom(position));
+ }
+
+ /**
+ * @return {boolean} true if the piece at {@param position} matches the color that has to play now,
+ * or if "enforcedColorTurn" is disabled
+ */
+ isAllowedToPlayPieceAt(position) {
+ if (this.#enforceColorTurn) {
+ return this.#hasPieceAt(position) && this.isColorToPlay(this.getPieceAt(position));
+ } else {
+ // FIXME: here we don't check it's not empty? doesn't seem consistent
+ return true;
+ }
+ }
+
+ getColorToPlay() {
+ if (this.#redToPlay) {
+ return Color.RED;
+ } else {
+ return Color.BLACK;
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @return {boolean}
+ */
+ isColorToPlay(piece) {
+ return (piece.color === Color.RED && this.#redToPlay) || (piece.color === Color.BLACK && !this.#redToPlay);
+ }
+
+ /**
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#redToPlay = (color === Color.RED);
+ }
+
+ /**
+ * Return captured piece, or null if no piece was taken
+ */
+ registerMove(move) {
+ let piece = this.getPieceAt(move.from);
+ if (piece == null) {
+ throw new Error('No piece to move at ' + move.from);
+ } else if (this.#enforceColorTurn && !this.isColorToPlay(piece)) {
+ throw new Error(`It is ${this.getColorToPlay()}'s turn to play`);
+ } else {
+ if (this.#isPossibleMove(move)) {
+ return this.#registerMove(piece, move)
+ } else {
+ throw new Error(`Move ${move} is not possible for ${piece.pieceChar}`);
+ }
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param move {HalfMove}
+ * @return {PhysicalPiece|null} captured piece, or null if no piece was taken
+ */
+ #registerMove(piece, move) {
+ let targetPiece = this.getPieceAt(move.to);
+ this.removePieceFrom(move.from);
+ this.#setPieceAt(piece, move.to);
+ this.#redToPlay = !this.#redToPlay;
+ if (piece.color === Color.BLACK) {
+ this.#fullMovesCounts++;
+ }
+ return targetPiece;
+ }
+
+ /**
+ * @param color {string}
+ * @return {boolean}
+ */
+ isInCheck(color) {
+ let generalPosition = this.findGeneral(color).position;
+ return this
+ .#listAllMovesForColor(reverseColor(color))
+ .map(move => move.to)
+ .some(position => position.isEqualsTo(generalPosition));
+ }
+
+ isCheckmate(color) {
+ return color === this.getColorToPlay() && this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ isStalemate(color) {
+ return color === this.getColorToPlay() && !this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ #allMovesAreIllegal(color) {
+ return this.#listAllMovesForColor(color).every(move => this.#isMoveIllegal(move, color));
+ }
+
+ isMated() {
+ return this.isCheckmate(Color.RED) ||
+ this.isCheckmate(Color.BLACK) ||
+ this.isStalemate(Color.RED) ||
+ this.isStalemate(Color.BLACK);
+ }
+
+ /**
+ * @param color
+ * @return {PieceAtPosition|null}
+ */
+ findGeneral(color) {
+ return this
+ .listPiecePositions()
+ .find(piecePosition =>
+ piecePosition.piece.pieceChar.toUpperCase() === 'K' && piecePosition.isColor(color)
+ );
+ }
+
+ #isMoveIllegal(move, color) {
+ let boardCopy = this.copy();
+ if (boardCopy.#isPossibleMove(move)) {
+ boardCopy.registerMove(move);
+ return boardCopy.#areGeneralsFacing() || boardCopy.isInCheck(color);
+ } else {
+ return true;
+ }
+ }
+
+ #areGeneralsFacing() {
+ let red = this.findGeneral(Color.RED);
+ let black = this.findGeneral(Color.BLACK);
+ if (red == null || black == null) {
+ return false;
+ } else {
+ let redGeneralPosition = red.position;
+ let blackGeneralPosition = black.position;
+ return red.position.x === black.position.x && this.#noPieceOnFileBetween(redGeneralPosition, blackGeneralPosition);
+ }
+ }
+
+ #noPieceOnFileBetween(p1, p2) {
+ if (p1.x !== p2.x) {
+ return false;
+ } else {
+ for (let y = p1.y + 1; y < p2.y; y++) {
+ let position = new Position(p1.x, y);
+ if (this.#hasPieceAt(position)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ #listAllMovesForColor(color) {
+ return this
+ .listPiecePositions()
+ .filter(piecePosition => piecePosition.isColor(color))
+ .flatMap(piecePosition => this.#listAllMovesFrom(piecePosition.position));
+ }
+
+ #isPossibleMove(move) {
+ return this
+ .#listAllMovesFromAsPositions(move.from)
+ .some(targetPosition => Position.areEquals(move.to, targetPosition));
+ }
+
+ isLegalMove(move) {
+ let color = this.getColorAt(move.from);
+ if (color == null) {
+ return false;
+ } else {
+ return !this.#isMoveIllegal(move, color);
+ }
+ }
+
+ /**
+ * @returns {HalfMove[]}
+ */
+ listLegalMovesFrom(position) {
+ let color = this.getColorAt(position);
+ if (color == null) {
+ return [];
+ } else {
+ return this.#listAllMovesFrom(position).filter(move => !this.#isMoveIllegal(move, color));
+ }
+ }
+
+ /**
+ * Lists every legal move available to the given color (defaults to the
+ * color whose turn it is to play).
+ *
+ * @param color {string} optional, defaults to {@link getColorToPlay}
+ * @returns {HalfMove[]}
+ */
+ listAllLegalMoves(color = this.getColorToPlay()) {
+ const moves = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ const pos = new Position(x, y);
+ if (this.getColorAt(pos) === color) {
+ moves.push(...this.listLegalMovesFrom(pos));
+ }
+ }
+ }
+ return moves;
+ }
+
+ /**
+ * @returns Array of {@class HalfMove} one can go to from {@param from}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFrom(from) {
+ return this
+ .#listAllMovesFromAsPositions(from)
+ .map(to => new HalfMove(from, to));
+ }
+
+ /**
+ * @returns {Position[]} one can go to from {@param position}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFromAsPositions(position) {
+ switch (this.getPieceAt(position).pieceChar.toLowerCase()) {
+ case 'c':
+ return this.#listMovesForCannon(position);
+ case 'r':
+ return this.#listMovesForChariot(position);
+ case 'n':
+ return this.#listMovesForHorse(position);
+ case 'b':
+ return this.#listMovesForElephant(position);
+ case 'a':
+ return this.#listMovesForAdvisor(position);
+ case 'k':
+ return this.#listMovesForGeneral(position);
+ case 'p':
+ return this.#listMovesForSoldier(position);
+ default:
+ throw new Error('Not implemented for ' + this.getPieceAt(position).pieceChar)
+ }
+ }
+
+ #listMovesForCannon(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForCannon(position, allTargetPosition) {
+ let result = [];
+ let foundPivot = false;
+
+ for (let i = 0; i < allTargetPosition.length; i++) {
+ let targetPosition = allTargetPosition[i];
+ if (!foundPivot) {
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else {
+ foundPivot = true;
+ }
+ } else {
+ if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForChariot(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForChariot(position, allTargetPositions) {
+ let result = [];
+
+ for (let i = 0; i < allTargetPositions.length; i++) {
+ let targetPosition = allTargetPositions[i];
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForHorse(position) {
+ let result = [];
+ let top = position.getTop();
+ let bottom = position.getBottom();
+ let left = position.getLeft();
+ let right = position.getRight();
+ if (top.existsOnBoard() && !this.#hasPieceAt(top)) {
+ result.push(top.getTopLeft());
+ result.push(top.getTopRight());
+ }
+ if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) {
+ result.push(bottom.getBottomLeft());
+ result.push(bottom.getBottomRight());
+ }
+ if (left.existsOnBoard() && !this.#hasPieceAt(left)) {
+ result.push(left.getTopLeft());
+ result.push(left.getBottomLeft());
+ }
+ if (right.existsOnBoard() && !this.#hasPieceAt(right)) {
+ result.push(right.getTopRight());
+ result.push(right.getBottomRight());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(targetPosition, position)
+ );
+ }
+
+ #listMovesForElephant(position) {
+ let result = [];
+ let topLeft = position.getTopLeft();
+ let topRight = position.getTopRight();
+ let bottomRight = position.getBottomRight();
+ let bottomLeft = position.getBottomLeft();
+
+ if (topLeft.existsOnBoard() && !this.#hasPieceAt(topLeft)) {
+ result.push(topLeft.getTopLeft());
+ }
+ if (topRight.existsOnBoard() && !this.#hasPieceAt(topRight)) {
+ result.push(topRight.getTopRight());
+ }
+ if (bottomRight.existsOnBoard() && !this.#hasPieceAt(bottomRight)) {
+ result.push(bottomRight.getBottomRight());
+ }
+ if (bottomLeft.existsOnBoard() && !this.#hasPieceAt(bottomLeft)) {
+ result.push(bottomLeft.getBottomLeft());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() &&
+ !this.containSameColors(position, targetPosition) &&
+ !this.#areOnOppositeSidesOfTheRiver(position, targetPosition)
+ );
+ }
+
+ #listMovesForAdvisor(position) {
+ let result = [];
+ result.push(position.getTopLeft());
+ result.push(position.getTopRight());
+ result.push(position.getBottomRight());
+ result.push(position.getBottomLeft());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #listMovesForGeneral(position) {
+ let result = [];
+ result.push(position.getTop());
+ result.push(position.getBottom());
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #filterMovesInItsPalace(position, allTargetPosition) {
+ if (this.#hasRedPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInRedPalace() && !this.#hasRedPieceAt(target)
+ );
+ } else if (this.#hasBlackPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInBlackPalace() && !this.#hasBlackPieceAt(target)
+ );
+ } else {
+ console.error("we should never pass here")
+ return [];
+ }
+ }
+
+ #listMovesForSoldier(position) {
+ let result = [];
+ if (this.#hasRedPieceAt(position)) {
+ // is red
+ result.push(position.getTop());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.redGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else if (this.#hasBlackPieceAt(position)) {
+ // is black
+ result.push(position.getBottom());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.blackGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else {
+ console.warn("we should never pass here")
+ return [];
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition)
+ );
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {null|string}
+ */
+ getColorAt(position) {
+ let piece = this.getPieceAt(position);
+ if (piece != null) {
+ return piece.color;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasPieceAt(position) {
+ return this.#content[position.x][position.y] != null;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasRedPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.RED, position);
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasBlackPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.BLACK, position);
+ }
+
+ #hasPieceOfColorAt(color, position) {
+ let piece = this.getPieceAt(position);
+ return piece != null && piece.color === color;
+ }
+
+ #areOnOppositeSidesOfTheRiver(p1, p2) {
+ return (p1.y <= 4 && p2.y >= 5) || (p2.y <= 4 && p1.y >= 5);
+ }
+
+ containSameColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasRedPieceAt(p2))
+ || (this.#hasBlackPieceAt(p1) && this.#hasBlackPieceAt(p2));
+ }
+
+ containOppositeColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasBlackPieceAt(p2))
+ || (this.#hasRedPieceAt(p2) && this.#hasBlackPieceAt(p1));
+ }
+
+ printBoardToLines() {
+ let lines = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ let row = '';
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let piece = this.getPieceAt(new Position(x, y));
+ if (piece == null) {
+ row += ' ';
+ } else {
+ row += piece.pieceChar + ' ';
+ }
+ }
+ lines.push(row);
+ }
+ return lines;
+ }
+
+ printBoard() {
+ let oneLine = '';
+ this.printBoardToLines().forEach(line => oneLine += line + '\n');
+ console.log(oneLine);
+ }
+
+ /**
+ * @return {Board}
+ */
+ copy() {
+ let copy = new Board();
+ copy.loadFen(this.outputFen());
+ return copy
+ }
+
+}
diff --git a/webapp/src/main/resources/public/dist/0.0.4/board-gui.js b/webapp/src/main/resources/public/dist/0.0.4/board-gui.js
new file mode 100644
index 000000000..19affafc5
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.4/board-gui.js
@@ -0,0 +1,1855 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see .
+ */
+
+// Small DOM helpers and constants, inlined here so this file is usable on its
+// own (only requires xiangqi.js, which provides the `Color` enum). Previously
+// imported from utils.js / ui.js. For helpers that may also be defined by
+// those modules when loaded as part of the full webapp, we only define them
+// if the host page hasn't already loaded those modules, to avoid clobbering
+// existing definitions.
+if (typeof buildImg === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildImg = function (src, className = null) {
+ const img = document.createElement('img');
+ img.src = src;
+ if (className != null) img.className = className;
+ return img;
+ };
+}
+
+if (typeof htmlCollectionToArray === 'undefined') {
+ // eslint-disable-next-line no-var
+ var htmlCollectionToArray = function (htmlCollection) {
+ const arr = [];
+ for (let i = 0; i < htmlCollection.length; i++) {
+ arr.push(htmlCollection[i]);
+ }
+ return arr;
+ };
+}
+
+if (typeof buildDivWithClass === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildDivWithClass = function (className) {
+ const div = document.createElement('div');
+ div.className = className;
+ return div;
+ };
+}
+
+// Mapping from FEN piece char to piece image file name. Lives here (rather
+// than in ui.js) so that board-gui.js can be used standalone.
+const pieceImageNames = new Map();
+pieceImageNames.set('K', 'red_general.png');
+pieceImageNames.set('k', 'black_general.png');
+pieceImageNames.set('A', 'red_advisor.png');
+pieceImageNames.set('a', 'black_advisor.png');
+pieceImageNames.set('B', 'red_elephant.png');
+pieceImageNames.set('b', 'black_elephant.png');
+pieceImageNames.set('N', 'red_horse.png');
+pieceImageNames.set('n', 'black_horse.png');
+pieceImageNames.set('C', 'red_cannon.png');
+pieceImageNames.set('c', 'black_cannon.png');
+pieceImageNames.set('R', 'red_chariot.png');
+pieceImageNames.set('r', 'black_chariot.png');
+pieceImageNames.set('P', 'red_soldier.png');
+pieceImageNames.set('p', 'black_soldier.png');
+
+const diagonalDescending = ' ';
+const diagonalRising = ' ';
+
+const diagonalDescendingMini = ' ';
+const diagonalRisingMini = ' ';
+
+const PRIMARY_ARROW_COLOR = 'rgb(27, 181, 29)';
+const SECONDARY_ARROW_COLOR = 'rgb(62, 56, 219)';
+
+const ARROW_MARKERS = `
+
+
+
+
+
+
+
+ `;
+
+const bottomRightCrosshairs = [
+ new Position(0, 8), new Position(6, 8),
+ new Position(1, 7), new Position(3, 7), new Position(5, 7), new Position(7, 7),
+ new Position(1, 4), new Position(3, 4), new Position(5, 4), new Position(7, 4),
+ new Position(0, 3), new Position(6, 3),
+];
+
+const bottomLeftCrosshairs = [
+ new Position(1, 8), new Position(7, 8),
+ new Position(0, 7), new Position(2, 7), new Position(4, 7), new Position(6, 7),
+ new Position(0, 4), new Position(2, 4), new Position(4, 4), new Position(6, 4),
+ new Position(1, 3), new Position(7, 3),
+];
+
+const topRightCrosshairs = [
+ new Position(0, 7), new Position(6, 7),
+ new Position(1, 6), new Position(3, 6), new Position(5, 6), new Position(7, 6),
+ new Position(1, 3), new Position(3, 3), new Position(5, 3), new Position(7, 3),
+ new Position(0, 2), new Position(6, 2),
+];
+
+const topLeftCrosshairs = [
+ new Position(1, 7), new Position(7, 7),
+ new Position(0, 6), new Position(2, 6), new Position(4, 6), new Position(6, 6),
+ new Position(0, 3), new Position(2, 3), new Position(4, 3), new Position(6, 3),
+ new Position(1, 2), new Position(7, 2)
+];
+
+const EngineArrowType = Object.freeze({
+ PRIMARY: 'PRIMARY',
+ SECONDARY: 'SECONDARY'
+});
+
+/**
+ * Orientation of the board coordinate labels.
+ * Use `null` (not part of this enum) to hide the coordinates entirely.
+ */
+const CoordinatesOrientation = Object.freeze({
+ WXF: 'WXF',
+ UCI: 'UCI',
+});
+
+// Chinese numerals for files 1..9, used to label files in WXF mode when the
+// {@link FileNumbersStyle} setting calls for Chinese numerals on that side.
+const CHINESE_FILE_DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九'];
+
+/**
+ * How file numbers are rendered around the board in WXF mode. Only affects the
+ * file-number labels; the UCI orientation (a..i letters) is unaffected.
+ */
+const FileNumbersStyle = Object.freeze({
+ /** Arabic numerals (1..9) on both sides of the board. */
+ ARABIC_BOTH: 'ARABIC_BOTH',
+ /** Chinese numerals (一..九) on both sides of the board. */
+ CHINESE_BOTH: 'CHINESE_BOTH',
+ /** Chinese numerals on red's side; Arabic numerals on black's side (default). */
+ CHINESE_RED_ONLY: 'CHINESE_RED_ONLY',
+ DEFAULT: 'CHINESE_RED_ONLY',
+});
+
+/**
+ * Visual style of the board pieces. Used to pick the corresponding image folder
+ * (under `${assetsBaseUrl}/images/pieces//`).
+ */
+const PieceStyleSetting = Object.freeze({
+ TRADITIONAL: 'TRADITIONAL',
+ ROMANIZED_ROUNDED: 'ROMANIZED_ROUNDED',
+ DEFAULT: 'TRADITIONAL',
+});
+
+/**
+ * @typedef {Object} BoardGuiOptions
+ * @property {string} [elementId] - id of the container element
+ * @property {boolean} [showCoordinates] - whether to reserve space for file/rank coordinates
+ * @property {string|null} [coordinatesOrientation] - one of {@link CoordinatesOrientation} or `null` to
+ * hide the labels (space is still reserved when
+ * `showCoordinates` is true). The caller is in
+ * charge of resolving any user preference (e.g. cookies).
+ * @property {boolean} [mini] - whether this is a mini (thumb) board
+ * @property {boolean} [forceRenderChecks] - render checks even on mini boards
+ * @property {boolean} [svg] - enable the SVG overlay (used for engine arrows)
+ * @property {string} [assetsBaseUrl] - base URL prepended to every static asset path
+ * (images, audio). Default: `https://elephantchess.io`.
+ * Pass an empty string to use relative paths (e.g. when
+ * serving the assets from the current host on localhost).
+ * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece
+ * image folder.
+ * @property {boolean} [colorblindFriendlyBlackPieces] - if true, black piece images get an invert
+ * CSS filter for improved contrast.
+ * @property {string} [fileNumbersStyle] - one of {@link FileNumbersStyle}; selects how
+ * file numbers are rendered in WXF mode.
+ */
+
+/** @type {Readonly>} */
+const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({
+ elementId: 'board-container',
+ showCoordinates: true,
+ coordinatesOrientation: 'WXF',
+ mini: false,
+ forceRenderChecks: false,
+ svg: false,
+ assetsBaseUrl: 'https://cdn.elephantchess.io/static',
+ pieceStyle: PieceStyleSetting.DEFAULT,
+ colorblindFriendlyBlackPieces: false,
+ fileNumbersStyle: FileNumbersStyle.DEFAULT,
+});
+
+class BoardGui {
+
+ #board = new Board();
+ #boardContainer;
+ #currentShowingLegalMovesFor = null;
+ #selectedPiecePosition = null;
+ #flippedRed = true; // if true, oriented toward the RED player
+ #afterMoveListeners = [];
+ #afterDrawPositionsListeners = [];
+ #afterFlipListeners = [];
+
+ /** @type {HTMLAudioElement} */
+ #clickSound;
+
+ // when false, player won't be able to make a move
+ #isPlayerMoveEnabled = true;
+
+ /**
+ * @type {Readonly>}
+ */
+ #options;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #primaryEngineArrow = null;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #secondaryEngineArrow = null;
+
+ #isSafari = false;
+
+ /**
+ * @param {BoardGuiOptions} [options]
+ */
+ constructor(options = {}) {
+ this.#isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ this.#options = Object.freeze({...DEFAULT_BOARD_GUI_OPTIONS, ...options});
+
+ this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`);
+
+ this.#boardContainer = document.getElementById(this.#options.elementId);
+ this.#renderColorblindFriendlyBlackPiecesSetting(this.#options.colorblindFriendlyBlackPieces);
+ this.#drawBoard();
+ this.#drawPieces(); // FIXME: useful?
+
+ const boardGui = this;
+
+ document
+ .getElementsByTagName('html')
+ .item(0)
+ .addEventListener('click', (e) => {
+ if (!boardGui.#boardContainer.contains(e.target)) {
+ boardGui.#hideAllPiecePlaceHolders();
+ }
+ });
+
+ if (this.#options.svg) {
+ window.onresize = function () {
+ boardGui.#renderSvg();
+ };
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Draw the position described by {@code fen}.
+ *
+ * When {@code animate} is true, the transition from the currently displayed
+ * position to the target position is animated: unchanged pieces are left in
+ * place, pieces of the same type are paired between the current and target
+ * positions and slide from their source square to their destination square,
+ * leftover current pieces fade out, and target pieces fade in - all
+ * simultaneously. This works for any diff size (not just a single move),
+ * similar to what lichess does.
+ *
+ * @param fen {string}
+ * @param animate {boolean}
+ */
+ loadFen(fen, animate = false) {
+ if (this.isInPlaceHolderMode()) {
+ console.warn('Can not load FEN when in placeholder mode');
+ return;
+ }
+
+ if (animate) {
+ this.#drawPositionAnimated(fen);
+ } else {
+ this.#drawPositionNoAnimation(fen);
+ }
+ }
+
+ #drawPositionNoAnimation(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+ this.#unDrawAllPieces();
+ this.#board.loadFen(targetFen);
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Compute the target position (via a temporary board so we don't mutate the
+ * current {@link Board} model before we're ready) and diff it against the
+ * currently displayed pieces to build the animation plan.
+ *
+ * @param targetFen {string}
+ */
+ #drawPositionAnimated(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+
+ // compute the target piece layout via a temp board (no DOM side-effects,
+ // and no mutation of the real model yet)
+ const targetBoard = new Board();
+ targetBoard.loadFen(targetFen);
+ const targetPieces = targetBoard.listPiecePositions();
+
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ // commit the new board state in the model
+ this.#board.loadFen(targetFen);
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#afterDrawPositionsListeners.forEach(listener => listener());
+ });
+ }
+
+ outputFen() {
+ return this.#board.outputFen();
+ }
+
+ /**
+ * Returns a copy of the underlying (non-GUI) {@link Board}, useful when
+ * callers need to inspect/query the board model without touching the DOM.
+ *
+ * A copy is returned rather than the internal instance to preserve
+ * encapsulation (so callers can't mutate the BoardGui's state directly).
+ *
+ * @return {Board}
+ */
+ get board() {
+ return this.#board.copy();
+ }
+
+ /**
+ * Change which color is to play next (doesn't alter the pieces on the board).
+ *
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#board.forceColorToPlay(color);
+ }
+
+ get isPlayerMoveEnabled() {
+ return this.#isPlayerMoveEnabled;
+ }
+
+ set isPlayerMoveEnabled(value) {
+ if (value) {
+ this.enablePlayerMove();
+ } else {
+ this.disablePlayerMove();
+ }
+ }
+
+ disablePlayerMove() {
+ this.#isPlayerMoveEnabled = false;
+ this.#hideAllPiecePlaceHolders();
+ this.#hideAllDraggableCursors();
+ }
+
+ enablePlayerMove() {
+ this.#isPlayerMoveEnabled = true;
+ this.#resetDraggableCursors();
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterMoveListener(listener) {
+ this.#afterMoveListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterDrawPositionsListener(listener) {
+ this.#afterDrawPositionsListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function(string)}
+ */
+ addAfterFlipListener(listener) {
+ this.#afterFlipListeners.push(listener);
+ }
+
+ clearAllAfterMovesListeners() {
+ this.#afterMoveListeners = [];
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param highLastMove {boolean} if true, the last move will be highlighted with a green dashed circle
+ * @param afterMoveCallback {function}
+ */
+ registerOpponentMove(move, highLastMove, afterMoveCallback) {
+ this.#hideHighlightedLastMove();
+ this.#animateMoveViaDiff(move, () => {
+ if (highLastMove) {
+ this.highlightLastMove(move);
+ }
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#clickSound
+ .play()
+ .catch(e => {
+ // ignored, spam error in console in dev
+ });
+ if (afterMoveCallback != null) {
+ afterMoveCallback()
+ }
+ });
+ }
+
+ /**
+ * Animate the DOM pieces from the currently displayed position (as
+ * described by {@link #board}) to {@code targetPieces}. Does NOT mutate the
+ * underlying board model; the caller is expected to update the model in
+ * {@code onDone}.
+ *
+ * Diff strategy (mirrors lichess/chessground):
+ * - pieces of the same pieceChar on the same square: leave image alone
+ * - current-board misplacements and target-board misplacements of the
+ * same pieceChar: paired greedily by shortest distance and animated
+ * with a CSS transform transition
+ * - unpaired leftover current-board misplacements: faded out
+ * - unpaired leftover target-board misplacements: faded in
+ *
+ * @param targetPieces {PieceAtPosition[]}
+ * @param durationMs {number}
+ * @param onDone {function}
+ */
+ #animatePiecesTo(targetPieces, durationMs, onDone) {
+ const currentPieces = this.#board.listPiecePositions();
+
+ const posKey = p => `${p.toUci()}`;
+ const currentMap = new Map(currentPieces.map(pp => [posKey(pp.position), pp]));
+ const targetMap = new Map(targetPieces.map(pp => [posKey(pp.position), pp]));
+
+ // pieces currently drawn on the board that don't match the target
+ // position (either the target expects a different piece on that
+ // square, or no piece at all). Each will be either animated to a
+ // square in targetBoardMisplacements or faded out.
+ /** @type {PieceAtPosition[]} */
+ const currentBoardMisplacements = [];
+ for (const [positionKey, currentPp] of currentMap) {
+ const targetPp = targetMap.get(positionKey);
+ if (targetPp && targetPp.piece.pieceChar === currentPp.piece.pieceChar) {
+ // same piece on same square: leave image alone
+ targetMap.delete(positionKey);
+ } else {
+ currentBoardMisplacements.push(currentPp);
+ }
+ }
+
+ // pieces required by the target position that aren't already correctly
+ // drawn on the current board (the square is empty or holds a different
+ // piece). Each will be either the destination of an animated move from
+ // currentBoardMisplacements or faded in as a new image.
+ /** @type {PieceAtPosition[]} */
+ const targetBoardMisplacements = Array.from(targetMap.values());
+
+ // pair movers greedily by closest same-pieceChar distance
+ const calculateDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+ const candidatePairs = [];
+ currentBoardMisplacements.forEach((c, i) => {
+ targetBoardMisplacements.forEach((t, j) => {
+ if (t.piece.pieceChar === c.piece.pieceChar) {
+ candidatePairs.push({i, j, distance: calculateDistance(c.position, t.position)});
+ }
+ });
+ });
+ candidatePairs.sort((a, b) => a.distance - b.distance);
+
+ // indices into currentBoardMisplacements / targetBoardMisplacements that
+ // have already been claimed by an animated move, so we don't pair the
+ // same piece twice while walking the distance-sorted candidate list.
+ /** @type {Set} */
+ const usedCurrent = new Set();
+ /** @type {Set} */
+ const usedTarget = new Set();
+ const animatedMoves = [];
+ for (const {i, j} of candidatePairs) {
+ if (!usedCurrent.has(i) && !usedTarget.has(j)) {
+ usedCurrent.add(i);
+ usedTarget.add(j);
+ animatedMoves.push({
+ from: currentBoardMisplacements[i].position,
+ to: targetBoardMisplacements[j].position,
+ pieceChar: currentBoardMisplacements[i].piece.pieceChar,
+ });
+ }
+ }
+ const toRemove = currentBoardMisplacements.filter((_, i) => !usedCurrent.has(i));
+ const toAdd = targetBoardMisplacements.filter((_, j) => !usedTarget.has(j));
+
+ // nothing to animate: bail out early
+ if (animatedMoves.length === 0 && toRemove.length === 0 && toAdd.length === 0) {
+ onDone();
+ return;
+ }
+
+ const transitionStr = `transform ${durationMs}ms ease, opacity ${durationMs}ms ease`;
+ const getSquareRect = pos => document
+ .getElementById(this.#positionToElementId('square', pos))
+ .getBoundingClientRect();
+
+ const cleanups = [];
+
+ // animate movers: translate the source-square image toward the target square
+ animatedMoves.forEach(animatedMove => {
+ const img = document.getElementById(this.#positionToElementId('image', animatedMove.from));
+ if (img == null) return;
+ const fromRect = getSquareRect(animatedMove.from);
+ const toRect = getSquareRect(animatedMove.to);
+ const dx = toRect.left - fromRect.left;
+ const dy = toRect.top - fromRect.top;
+
+ // rename id so it doesn't conflict with drawPieceAt on the target square
+ img.id = `animating-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ // must stay above .crosshair-square (z-index: 80) and resting
+ // pieces (z-index: 100) so the moving piece isn't painted under
+ // crosshairs of neighboring squares it flies over
+ img.style.zIndex = '110';
+ img.style.pointerEvents = 'none';
+
+ // trigger on next frame so the transition actually runs
+ requestAnimationFrame(() => {
+ img.style.transform = `translate(${dx}px, ${dy}px)`;
+ });
+
+ cleanups.push(() => img.remove());
+ });
+
+ // fade out leftover current pieces (captures / removals)
+ toRemove.forEach(rm => {
+ const img = document.getElementById(this.#positionToElementId('image', rm.position));
+ if (img == null) return;
+ img.id = `fading-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ img.style.pointerEvents = 'none';
+ requestAnimationFrame(() => {
+ img.style.opacity = '0';
+ });
+ cleanups.push(() => img.remove());
+ });
+
+ // fade in leftover target pieces (newly placed on a previously empty square)
+ const addedImages = [];
+ toAdd.forEach(add => {
+ this.#drawPieceAt(add.piece.pieceChar, add.position);
+ const img = document.getElementById(this.#positionToElementId('image', add.position));
+ if (img != null) {
+ img.style.opacity = '0';
+ img.style.transition = transitionStr;
+ addedImages.push(img);
+ requestAnimationFrame(() => {
+ img.style.opacity = '1';
+ });
+ }
+ });
+
+ setTimeout(() => {
+ // drop transient animation images
+ cleanups.forEach(c => c());
+
+ // draw the final image at each move's target square
+ animatedMoves.forEach(mv => {
+ const existing = document.getElementById(this.#positionToElementId('image', mv.to));
+ if (existing != null) existing.remove();
+ this.#drawPieceAt(mv.pieceChar, mv.to);
+ });
+
+ // clear transient styles on faded-in images
+ addedImages.forEach(img => {
+ img.style.transition = '';
+ img.style.opacity = '';
+ });
+
+ onDone();
+ this.#forceSafariLayoutRefresh();
+ }, durationMs + 20);
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param animate {boolean}
+ */
+ registerMoveIfLegal(move, animate = true) {
+ if (!this.#board.isLegalMove(move)) {
+ console.log(move + ' is not a legal move');
+ return;
+ }
+
+ const afterCommit = () => {
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ for (let i = 0; i < this.#afterMoveListeners.length; i++) {
+ this.#afterMoveListeners[i](move);
+ }
+ };
+
+ if (animate) {
+ // the diff-based animator will commit the model via its onDone
+ this.#animateMoveViaDiff(move, afterCommit);
+ } else {
+ const piece = this.#board.getPieceAt(move.from);
+ this.#board.registerMove(move);
+ this.#drawMove(piece.pieceChar, move);
+ afterCommit();
+ }
+ }
+
+ /**
+ * Animate a single move by delegating to the generic diff-based animator
+ * {@link #animatePiecesTo}. The target position is computed on a copy of
+ * the current board so the real model is only mutated once the animation
+ * completes. This replaces the old xiangqi-shape-aware setInterval based
+ * {@code #animateMove}.
+ *
+ * @param move {HalfMove}
+ * @param onDone {function}
+ */
+ #animateMoveViaDiff(move, onDone) {
+ const targetBoard = this.#board.copy();
+ targetBoard.registerMove(move);
+ const targetPieces = targetBoard.listPiecePositions();
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ this.#board.registerMove(move);
+ onDone();
+ });
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ this.#board.addPieceAt(pieceChar, position, enforcePlacementRules);
+ this.#unDrawPiece(position);
+ this.#drawPieceAt(pieceChar, position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#board.removePieceFrom(position);
+ this.#unDrawPiece(position);
+ }
+
+ // FIXME: feels like this could be private? encapsulated?
+ updateHighlightedChecks() {
+ this.#hideAllCheckHighlights();
+
+ if (!this.#options.mini || this.#options.forceRenderChecks) {
+ const redGeneral = this.#board.findGeneral(Color.RED);
+ const blackGeneral = this.#board.findGeneral(Color.BLACK);
+
+ // generals may be absent (e.g. when flip() is called before any
+ // FEN has been loaded, on an empty board); nothing to highlight.
+ if (redGeneral == null || blackGeneral == null) {
+ return;
+ }
+
+ if (this.#board.isInCheck(Color.RED)) {
+ if (this.#board.isCheckmate(Color.RED)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(redGeneral.position);
+ } else {
+ this.#highlightCheck(redGeneral.position);
+ }
+ }
+
+ if (this.#board.isInCheck(Color.BLACK)) {
+ if (this.#board.isCheckmate(Color.BLACK)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(blackGeneral.position);
+ } else {
+ this.#highlightCheck(blackGeneral.position);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ */
+ highlightLastMove(move) {
+ this.#highlightWithClass(move.from, 'highlighted-last-move');
+ this.#highlightWithClass(move.to, 'highlighted-last-move');
+ }
+
+ #hideAllCheckHighlights() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-check', 'highlighted-checkmate'));
+ }
+
+ #hideHighlightedLastMove() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-last-move'));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheck(position) {
+ this.#highlightWithClass(position, 'highlighted-check');
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheckMate(position) {
+ this.#highlightWithClass(position, 'highlighted-checkmate');
+ }
+
+ /**
+ * @param position {Position}
+ * @param className {string}
+ */
+ #highlightWithClass(position, className) {
+ this.#locateLegalMovePlaceHolderAt(position).classList.add(className);
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ isInPlaceHolderMode() {
+ return this.#boardContainer.classList.contains('board-container-placeholder');
+ }
+
+ enablePlaceholderMode() {
+ this.#boardContainer.classList.add('board-container-placeholder');
+ }
+
+ disablePlaceholderMode() {
+ this.#boardContainer.classList.remove('board-container-placeholder');
+ }
+
+ /**
+ * Clear the SVG layer, which atm only render analytics arrows.
+ */
+ clearSvg() {
+ if (this.#options.svg) {
+ const svg = document.getElementById('board-svg');
+ svg.innerHTML = '';
+ svg.innerHTML += ARROW_MARKERS;
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ */
+ addEngineArrow(move, type) {
+ if (this.#options.svg) {
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ this.#primaryEngineArrow = move;
+ break;
+ case EngineArrowType.SECONDARY:
+ this.#secondaryEngineArrow = move;
+ break;
+ }
+
+ this.#renderSvg();
+ }
+ }
+
+ #renderSvg() {
+ if (this.#options.svg) {
+ this.clearSvg();
+ const svg = document.getElementById('board-svg');
+ if (this.#primaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#primaryEngineArrow, EngineArrowType.PRIMARY));
+ }
+ if (this.#secondaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#secondaryEngineArrow, EngineArrowType.SECONDARY));
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ * @returns {SVGGeometryElement[]}
+ */
+ #buildMoveArrow(move, type) {
+ function isSmallMove(move) {
+ return (move.isVertical() && Math.abs(move.to.y - move.from.y) <= 1) ||
+ (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1)
+ }
+
+ function isKnightMove(move) {
+ const dx = Math.abs(move.to.x - move.from.x);
+ const dy = Math.abs(move.to.y - move.from.y);
+ return (dx === 1 && dy === 2) || (dx === 2 && dy === 1);
+ }
+
+ const boardBounds = this.#boardContainer.getBoundingClientRect();
+
+ const square1 = document.getElementById(this.#positionToElementId('square', move.from));
+ const square2 = document.getElementById(this.#positionToElementId('square', move.to));
+ const bound1 = square1.getBoundingClientRect();
+ const bound2 = square2.getBoundingClientRect();
+
+ const x1 = (bound1.left + bound1.width / 2) - boardBounds.left;
+ const y1 = (bound1.top + bound1.height / 2) - boardBounds.top;
+ const x2 = (bound2.left + bound2.width / 2) - boardBounds.left;
+ const y2 = (bound2.top + bound2.height / 2) - boardBounds.top;
+
+ // stroke width scales with the square size so the arrow looks consistent across viewports
+ // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered)
+ const strokeWidth = Math.min(16, bound1.width * 0.16);
+
+ let pathD, midpointX, midpointY;
+
+ if (isKnightMove(move)) {
+ // draw an elbowed arrow matching the horse's actual movement:
+ // segment 1: orthogonal (straight line, 1 square) — the horse's "leg"
+ // segment 2: diagonal (1 square) — the horse's diagonal step
+ const sourceReduction = bound1.width * 0.40;
+ // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the
+ // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly
+ // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth.
+ const destReduction = strokeWidth * 1.9;
+ const dxPx = x2 - x1;
+ const dyPx = y2 - y1;
+
+ // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty)
+ function shortenToward(px, py, tx, ty, r) {
+ const dx = tx - px;
+ const dy = ty - py;
+ const d = Math.sqrt(dx * dx + dy * dy);
+ return [px + (dx / d) * r, py + (dy / d) * r];
+ }
+
+ let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY;
+
+ const dyBoardSquares = Math.abs(move.to.y - move.from.y);
+
+ if (dyBoardSquares === 2) {
+ // vertical-dominant: horse moves 2 squares vertically then 1 diagonally
+ // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement
+ elbowX = x1;
+ elbowY = y1 + dyPx / 2;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ } else {
+ // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally
+ // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement
+ elbowX = x1 + dxPx / 2;
+ elbowY = y1;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ }
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = elbowX;
+ midpointY = elbowY;
+ } else {
+ const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
+
+ // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top)
+ let reduction;
+
+ // we can not reduce by as much when the arrow is already very small
+ // otherwise, the direction of the arrow flips and points the wrong way
+ if (isSmallMove(move)) {
+ // we remove 40% a square length
+ reduction = bound1.width * 0.40;
+ } else {
+ // we remove 50% a square length
+ reduction = bound1.width * 0.50;
+ }
+
+ const reducedDist = dist - reduction;
+ const ratio = reducedDist / dist;
+
+ const x1Prime = x2 + ratio * (x1 - x2);
+ const y1Prime = y2 + ratio * (y1 - y2);
+ const x2Prime = x1 + ratio * (x2 - x1);
+ const y2Prime = y1 + ratio * (y2 - y1);
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = (x1Prime + x2Prime) / 2;
+ midpointY = (y1Prime + y2Prime) / 2;
+ }
+
+ const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ arrowPath.setAttribute('d', pathD);
+ arrowPath.style.strokeWidth = `${strokeWidth}px`;
+ arrowPath.style.fill = 'none';
+ arrowPath.style.strokeLinejoin = 'round';
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ arrowPath.style.stroke = PRIMARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-primary)';
+ break;
+ case EngineArrowType.SECONDARY:
+ arrowPath.style.stroke = SECONDARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-secondary)';
+ break;
+ }
+
+ // draw number circle
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute('r', (bound1.width * 0.20).toString());
+ circle.setAttribute('cx', midpointX.toString());
+ circle.setAttribute('cy', midpointY.toString());
+ circle.setAttribute('fill', 'white');
+ circle.setAttribute('stroke-width', (bound1.width * 0.04).toString());
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ circle.setAttribute('stroke', PRIMARY_ARROW_COLOR);
+ break;
+ case EngineArrowType.SECONDARY:
+ circle.setAttribute('stroke', SECONDARY_ARROW_COLOR);
+ break;
+ }
+
+ // draw number text
+ const numberLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ numberLabel.setAttribute('x', midpointX.toString());
+ numberLabel.setAttribute('y', midpointY.toString());
+ numberLabel.setAttribute('text-anchor', 'middle');
+ numberLabel.setAttribute('dominant-baseline', 'middle');
+ numberLabel.setAttribute('font-size', (bound1.width * 0.25).toString() + 'px');
+ numberLabel.setAttribute('font-weight', 'bold');
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ numberLabel.setAttribute('fill', PRIMARY_ARROW_COLOR);
+ numberLabel.innerHTML = '1';
+ break;
+ case EngineArrowType.SECONDARY:
+ numberLabel.setAttribute('fill', SECONDARY_ARROW_COLOR);
+ numberLabel.innerHTML = '2';
+ break;
+ }
+
+ return [arrowPath, circle, numberLabel];
+ }
+
+ #handlePieceHolderClickEvent(e, position) {
+ let onlyClickedSquareAndNoImage = true;
+
+ let images = document.getElementsByClassName('piece-image');
+ for (let i = 0; i < images.length; i++) {
+ if (images[i].contains(e.target)) {
+ onlyClickedSquareAndNoImage = false;
+ break;
+ }
+ }
+
+ if (onlyClickedSquareAndNoImage) {
+ this.#clickedOnSquare(position);
+ }
+ }
+
+ #clickedOnPiece(position) {
+ if (this.#isPlayerMoveEnabled) {
+ let board = this.#board;
+ let selected = this.#selectedPiecePosition;
+
+ if (selected == null && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting the piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else if (Position.areEquals(selected, position)) {
+ // if the piece user just clicked on is the same as the one he had selected before,
+ // then user is un-selecting the piece
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containOppositeColors(selected, position)) {
+ // user is capturing the piece he clicked on
+ this.registerMoveIfLegal(new HalfMove(selected, position));
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containSameColors(selected, position) && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting another piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ }
+ }
+ }
+
+ #clickedOnSquare(position) {
+ if (this.#isPlayerMoveEnabled) {
+ if (this.#selectedPiecePosition != null) {
+ this.registerMoveIfLegal(new HalfMove(this.#selectedPiecePosition, position));
+ this.#hideAllPiecePlaceHolders();
+ }
+ }
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param move {HalfMove}
+ */
+ #drawMove(pieceChar, move) {
+ this.#unDrawPiece(move.from);
+ this.#unDrawPiece(move.to);
+ this.#drawPieceAt(pieceChar, move.to);
+ }
+
+ #drawBoard() {
+ /**
+ * @param positionType {string} - one of 'bottom-right', 'bottom-left', 'top-right', 'top-left'
+ * @param drawingPosition {Position}
+ * @param positions {Position[]}
+ * @returns {HTMLDivElement|null}
+ */
+ function drawCrosshairForPositionType(positionType, drawingPosition, positions) {
+ const crosshairToDraw = positions.find(crosshair => Position.areEquals(crosshair, drawingPosition));
+ if (crosshairToDraw != null) {
+ const crosshair = document.createElement('div');
+ crosshair.classList.add('crosshair-square', `crosshair-square-${positionType}`);
+ return crosshair;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param drawingPosition {Position}
+ * @returns {HTMLDivElement[]}
+ */
+ function drawCrosshairs(drawingPosition) {
+ const crosshairTypes = [
+ {type: 'bottom-right', positions: bottomRightCrosshairs},
+ {type: 'bottom-left', positions: bottomLeftCrosshairs},
+ {type: 'top-right', positions: topRightCrosshairs},
+ {type: 'top-left', positions: topLeftCrosshairs}
+ ];
+
+ return crosshairTypes
+ .map(({type, positions}) => drawCrosshairForPositionType(type, drawingPosition, positions))
+ .filter(crosshair => crosshair !== null);
+ }
+
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ // draw rows
+ let row = document.createElement('div');
+ row.className = 'rows';
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let renderPosition;
+ if (this.#flippedRed) {
+ renderPosition = new Position(x, y);
+ } else {
+ renderPosition = new Position(BOARD_WIDTH - x - 1, BOARD_HEIGHT - y - 1);
+ }
+
+ const pieceHolder = document.createElement('div');
+ pieceHolder.id = this.#positionToElementId('square', renderPosition);
+ pieceHolder.className = 'piece-holder';
+ pieceHolder.addEventListener('click', (e) => {
+ this.#handlePieceHolderClickEvent(e, renderPosition)
+ });
+
+ // drag and drop listeners
+ pieceHolder.addEventListener('dragover', (e) => e.preventDefault());
+ pieceHolder.addEventListener('dragenter', (e) => e.preventDefault());
+ pieceHolder.addEventListener('drop', (e) => {
+ this.#handlePieceHolderDropEvent(e, renderPosition);
+ });
+
+ const legalMovePlaceHolder = document.createElement('div');
+ legalMovePlaceHolder.id = this.#positionToElementId('legal_move', renderPosition);
+ pieceHolder.appendChild(legalMovePlaceHolder);
+
+ if (y === 5) {
+ if (x === 0) {
+ const river = buildDivWithClass('large-river');
+
+ if (!this.#options.mini) {
+ const riverOfTheChuContainer = buildDivWithClass('river-of-the-chu');
+ riverOfTheChuContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/river-of-the-chu-brown.png`));
+
+ const borderOfTheHanContainer = buildDivWithClass('border-of-the-han');
+ borderOfTheHanContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/border-of-the-han-brown.png`));
+
+ river.append(
+ riverOfTheChuContainer,
+ borderOfTheHanContainer
+ );
+ }
+
+ pieceHolder.append(river);
+ }
+ } else {
+ // draw row of visible squares
+ if (x < BOARD_WIDTH - 1 && y > 0) {
+ const visibleSquare = buildDivWithClass('visible-square');
+ pieceHolder.appendChild(visibleSquare);
+
+ if ((x === 3 && y === 2) || (x === 4 && y === 1) || (x === 3 && y === 9) || (x === 4 && y === 8)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalDescendingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalDescending;
+ }
+ }
+ if ((x === 3 && y === 1) || (x === 4 && y === 2) || (x === 3 && y === 8) || (x === 4 && y === 9)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalRisingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalRising;
+ }
+ }
+
+ if (x === BOARD_WIDTH - 2) {
+ visibleSquare.classList.add('visible-square-last-file');
+ }
+
+ // draw crosshairs
+ drawCrosshairs(new Position(x, y))
+ .forEach(element => visibleSquare.append(element));
+ }
+ }
+ row.appendChild(pieceHolder);
+ }
+ this.#boardContainer.appendChild(row);
+ }
+
+ // add bottom border to last row of visible squares
+ if (this.#flippedRed) {
+ for (let x = 0; x < BOARD_WIDTH - 1; x++) {
+ const position = new Position(x, 1);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ } else {
+ for (let x = BOARD_WIDTH - 1; x > 0; x--) {
+ const position = new Position(x, BOARD_HEIGHT - 2);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ }
+
+ this.#drawCoordinates();
+
+ // TODO: hacky
+ if (this.#isSafari) {
+ let rows = document.getElementsByClassName('rows');
+ for (let i = 0; i < rows.length; i++) {
+ rows[i].classList.add('safari-rows');
+ }
+
+ document.getElementById(this.#options.elementId).classList.add('safari-board-container');
+ }
+
+ if (this.#options.svg) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.id = 'board-svg';
+ this.#boardContainer.append(svg);
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ // https://stackoverflow.com/questions/9628507/how-can-i-tell-google-translate-to-not-translate-a-section-of-a-website
+ #drawCoordinates() {
+ function buildFileDiv(label, cssClass, visible) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = label;
+ if (visible) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = cssClass;
+ div.appendChild(span);
+ return div;
+ }
+
+ if (this.#options.showCoordinates) {
+ const orientation = this.#options.coordinatesOrientation;
+ const isSettingEnabled = orientation !== null;
+ // when the user has disabled coordinates we still reserve the space (hidden labels),
+ // so we must pick an arbitrary orientation for the (invisible) labels:
+ const isWfxOriented = orientation !== CoordinatesOrientation.UCI;
+
+ const fileNumbersStyle = this.#options.fileNumbersStyle;
+ // For a given side ('red' | 'black'), should we render Chinese numerals?
+ const isChineseOnSide = (side) => {
+ switch (fileNumbersStyle) {
+ case FileNumbersStyle.ARABIC_BOTH:
+ return false;
+ case FileNumbersStyle.CHINESE_BOTH:
+ return true;
+ case FileNumbersStyle.CHINESE_RED_ONLY:
+ default:
+ return side === 'red';
+ }
+ };
+
+ // draw top file coordinates
+ if (isWfxOriented) {
+ let topCoordinatesY = BOARD_HEIGHT - 1;
+ if (!this.#flippedRed) {
+ topCoordinatesY = 0;
+ }
+ // top row shows black's side when red is at the bottom, and red's side when flipped
+ const topSide = this.#flippedRed ? 'black' : 'red';
+ const topChinese = isChineseOnSide(topSide);
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label;
+ if (this.#flippedRed) {
+ // top row: file 1 is on the right (black perspective)
+ label = topChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString();
+ } else {
+ // top row: file 1 is on the left (board flipped, red on top)
+ label = topChinese
+ ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1]
+ : (BOARD_WIDTH - x).toString();
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, topCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+
+ // draw bottom file coordinates
+ let bottomCoordinatesY = 0;
+ if (!this.#flippedRed) {
+ bottomCoordinatesY = BOARD_HEIGHT - 1;
+ }
+ // bottom row shows red's side when red is at the bottom, and black's side when flipped
+ const bottomSide = this.#flippedRed ? 'red' : 'black';
+ const bottomChinese = isChineseOnSide(bottomSide);
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label;
+ if (isWfxOriented) {
+ if (this.#flippedRed) {
+ // bottom row: file 1 is on the left (red perspective)
+ label = bottomChinese
+ ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1]
+ : (BOARD_WIDTH - x).toString();
+ } else {
+ // bottom row: file 1 is on the right (board flipped, black at bottom)
+ label = bottomChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString();
+ }
+ } else {
+ label = UCI_LETTER[x];
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-bottom', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, bottomCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+
+ // draw right-side row coordinates
+ if (!isWfxOriented) {
+ let rightCoordinatesX = BOARD_WIDTH - 1;
+ if (!this.#flippedRed) {
+ rightCoordinatesX = 0;
+ }
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = (y + 1).toString();
+ if (isSettingEnabled) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = 'rows-coordinates-right';
+ div.appendChild(span);
+ let squareId = this.#positionToElementId('square', new Position(rightCoordinatesX, y));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+ }
+ }
+
+ #drawPieces() {
+ this.#board
+ .listPiecePositions()
+ .forEach(piecePosition => this.#drawPiece(piecePosition));
+ }
+
+ /**
+ * Resolves the URL of the image for a given piece char, using this board's
+ * configured `assetsBaseUrl` and `pieceStyle` options. Exposed so external
+ * widgets that share this board's visual identity (e.g. the position
+ * editor's piece palette) can use the exact same image set.
+ *
+ * @param pieceChar {string}
+ * @return {string}
+ */
+ getPieceImageSource(pieceChar) {
+ const style = this.#options.pieceStyle.toLowerCase();
+ return `${this.#options.assetsBaseUrl}/images/pieces/${style}/${pieceImageNames.get(pieceChar)}`;
+ }
+
+ /**
+ * @param piecePosition {PieceAtPosition}
+ */
+ #drawPiece(piecePosition) {
+ this.#drawPieceAt(piecePosition.piece.pieceChar, piecePosition.position);
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param position {Position}
+ */
+ #drawPieceAt(pieceChar, position) {
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ const img = document.createElement('img');
+ img.id = this.#positionToElementId('image', position);
+ img.className = 'piece-image';
+ if (isBlackPiece(pieceChar)) {
+ img.classList.add('piece-image-black');
+ }
+ img.setAttribute('src', this.getPieceImageSource(pieceChar));
+ img.addEventListener('click', () => this.#clickedOnPiece(position));
+ img.addEventListener('dragstart', (e) => this.#dragStart(e, position));
+ img.addEventListener('dragend', () => this.#dragEnd());
+ square.prepend(img);
+ }
+
+ /**
+ * Forces Safari to recalculate layout and positioning of board elements.
+ *
+ * Safari has known issues with layout calculations, particularly with CSS transforms,
+ * absolute positioning, and element positioning after DOM updates. This method applies
+ * various techniques to force Safari to trigger reflows and recalculate element positions.
+ *
+ * The method uses multiple approaches:
+ * - Hardware acceleration triggers via translateZ(0) transforms
+ * - Forced reflows by accessing offsetHeight
+ * - Position property manipulation on squares and piece images
+ *
+ * Called automatically after board state changes like moves, flips, and piece placement
+ * to ensure visual elements are correctly positioned in Safari.
+ *
+ * @private
+ */
+ #forceSafariLayoutRefresh() {
+ if (this.#isSafari) {
+ setTimeout(() => {
+ const container = this.#boardContainer;
+
+ // force container reflow
+ container.style.transform = 'translateZ(0)';
+ container.offsetHeight;
+ container.style.transform = '';
+
+ // force reflow on all squares
+ container
+ .querySelectorAll('.piece-holder')
+ .forEach(square => {
+ square.style.position = 'relative';
+ square.offsetHeight;
+ square.style.position = '';
+ });
+
+ // force reflow on all piece images
+ container
+ .querySelectorAll('.piece-image')
+ .forEach(piece => {
+ piece.style.transform = 'translateZ(0)';
+ piece.offsetHeight;
+ piece.style.transform = '';
+ });
+ }, 0);
+ }
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param position {Position}
+ */
+ #dragStart(e, position) {
+ console.log('drag start');
+
+ if (this.isPlayerMoveEnabled && this.#board.getColorAt(position) === this.#board.getColorToPlay()) {
+ e.dataTransfer.setData('text/plain', position.toUci());
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else {
+ e.preventDefault();
+ }
+ }
+
+ #dragEnd() {
+ console.log('drag end');
+
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param to {Position}
+ */
+ #handlePieceHolderDropEvent(e, to) {
+ const uci = e.dataTransfer.getData('text/plain');
+ const from = Position.parseUci(uci);
+ const move = new HalfMove(from, to);
+ this.registerMoveIfLegal(move, false);
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ #unDrawAllPieces() {
+ Position.getAll().forEach(position => this.#unDrawPiece(position));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #unDrawPiece(position) {
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ // NB: getElementsByClassName returns a live HTMLCollection; convert to a
+ // static array so removing elements doesn't skip siblings.
+ htmlCollectionToArray(square.getElementsByClassName('piece-image'))
+ .forEach(img => square.removeChild(img));
+ }
+
+ #hideAllPiecePlaceHolders() {
+ Position.getAll().forEach(position => {
+ // in case when the board is a mini board overview on an infinite scroll page
+ // the placeholder can be null if the miniboard has been discarded
+ // which would throw a bunch of errors in the console if we don't check for nullability
+ const placeHolder = this.#locateLegalMovePlaceHolderAt(position);
+ if (placeHolder != null) {
+ placeHolder.classList.remove(
+ 'legal-move-place-holder',
+ 'selected-piece',
+ 'possible-capture',
+ 'highlighted-last-move'
+ );
+ }
+ });
+
+ this.#selectedPiecePosition = null;
+ this.#currentShowingLegalMovesFor = null;
+ }
+
+ #showSelectedPositionAndLegalMovesPlaceHolders(position) {
+ // remove previous placeholders
+ this.#hideAllPiecePlaceHolders();
+
+ // show selected piece
+ this.#locateLegalMovePlaceHolderAt(position).classList.add('selected-piece');
+ this.#selectedPiecePosition = position;
+
+ // show legal moves and possible captures
+ this.#showLegalMovesPlaceHolders(position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #showLegalMovesPlaceHolders(position) {
+ this
+ .#board
+ .listLegalMovesFrom(position)
+ .map(move => move.to)
+ .forEach(targetPosition => {
+ let placeHolder = this.#locateLegalMovePlaceHolderAt(targetPosition);
+ if (this.#board.containOppositeColors(position, targetPosition)) {
+ placeHolder.classList.add('possible-capture');
+ } else {
+ placeHolder.classList.add('legal-move-place-holder');
+ }
+ });
+
+ this.#currentShowingLegalMovesFor = position;
+ }
+
+ highlightDebugMove(move, color) {
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-debug');
+ from.style.backgroundColor = color;
+ to.classList.add('highlighted-debug');
+ to.style.backgroundColor = color;
+ }
+
+ hideAllDebugHighlight() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-debug');
+ element.style.backgroundColor = null;
+ });
+ }
+
+ highlightDynamicMove(move) {
+ this.#hideAllPiecePlaceHolders();
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-move');
+ to.classList.add('highlighted-move');
+ }
+
+ hideAllHighlightedDynamicMoves() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-move');
+ });
+ }
+
+ clearBoard() {
+ this.#unDrawAllPieces();
+ this.#hideAllPiecePlaceHolders();
+ this.#board.clearBoard();
+ }
+
+ /**
+ * @param color {string|null}
+ */
+ flipToColor(color) {
+ if (color != null && ((this.#flippedRed && color === Color.BLACK) || (!this.#flippedRed && color === Color.RED))) {
+ this.flip();
+ }
+ }
+
+ /**
+ * Returns the color currently shown at the bottom of the board.
+ *
+ * @return {string}
+ */
+ get bottomColor() {
+ return this.#flippedRed ? Color.RED : Color.BLACK;
+ }
+
+ flip() {
+ this.#boardContainer.innerHTML = '';
+ this.#flippedRed = !this.#flippedRed;
+ this.#drawBoard();
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+
+ const newColor = this.#flippedRed ? Color.RED : Color.BLACK;
+ this.#afterFlipListeners.forEach(listener => listener(newColor));
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ *
+ * @param moveFormat {string}
+ */
+ updateMoveFormat(moveFormat) {
+ // easy solution: complete redraw (TODO: can more subtle)
+ this.#boardContainer.innerHTML = '';
+ this.#drawBoard();
+ this.#drawPieces();
+ }
+
+ reRenderPieces() {
+ this.#unDrawAllPieces();
+ this.#drawPieces();
+ }
+
+ /**
+ * Update the piece style and re-render the pieces. Needed because the
+ * options object is otherwise frozen and the piece style can be changed at
+ * runtime by the user (via the settings menu).
+ *
+ * @param pieceStyle {string} one of {@link PieceStyleSetting}
+ */
+ updatePieceStyle(pieceStyle) {
+ if (this.#options.pieceStyle === pieceStyle) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, pieceStyle});
+ this.reRenderPieces();
+ }
+
+ /**
+ * @param enabled {boolean}
+ */
+ setColorblindFriendlyBlackPiecesEnabled(enabled) {
+ if (this.#options.colorblindFriendlyBlackPieces === enabled) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, colorblindFriendlyBlackPieces: enabled});
+ this.#renderColorblindFriendlyBlackPiecesSetting(enabled);
+ }
+
+ /**
+ * Change which numeral system is used for file coordinates in WXF mode.
+ *
+ * @param fileNumbersStyle {string} one of {@link FileNumbersStyle}
+ */
+ setFileNumbersStyle(fileNumbersStyle) {
+ if (this.#options.fileNumbersStyle === fileNumbersStyle) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, fileNumbersStyle});
+ this.#redrawCoordinates();
+ }
+
+ /**
+ * Change the orientation (WXF numerals vs UCI letters) of the board coordinates.
+ *
+ * @param coordinatesOrientation {string|null} one of {@link CoordinatesOrientation} or
+ * `null` to keep the labels hidden
+ */
+ setCoordinatesOrientation(coordinatesOrientation) {
+ if (this.#options.coordinatesOrientation === coordinatesOrientation) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, coordinatesOrientation});
+ this.#redrawCoordinates();
+ }
+
+ #redrawCoordinates() {
+ // remove existing file-coordinate (top + bottom) and right-side rank labels
+ // (rank labels only exist in UCI mode but the selector is harmless if absent)
+ document.querySelectorAll(`#${this.#options.elementId} .file-coordinates-top,
+ #${this.#options.elementId} .file-coordinates-bottom,
+ #${this.#options.elementId} .rows-coordinates-right`)
+ .forEach(el => el.remove());
+ this.#drawCoordinates();
+ }
+
+ /**
+ * @return {boolean}
+ */
+ toggleShowCoordinates() {
+ let areVisible = this.#areCoordinatesVisible();
+ this.#renderShowCoordinatesSetting(!areVisible);
+ return !areVisible;
+ }
+
+ /**
+ * @param show {boolean}
+ */
+ #renderShowCoordinatesSetting(show) {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ label.style.visibility = show ? 'visible' : 'hidden';
+ }
+ }
+
+ /**
+ * @param enabled {boolean}
+ */
+ #renderColorblindFriendlyBlackPiecesSetting(enabled) {
+ this.#boardContainer.classList.toggle('colorblind-friendly-black-pieces', enabled);
+ }
+
+ #areCoordinatesVisible() {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ if (label.style.visibility === 'hidden') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ #hideAllDraggableCursors() {
+ this.#board
+ .listPiecePositions()
+ .forEach(pieceAtPosition => {
+ const imageId = this.#positionToElementId('image', pieceAtPosition.position);
+ const image = document.getElementById(imageId);
+ // image may be momentarily absent while an animation is in
+ // flight (source-square images are renamed to 'animating-xxx'
+ // until the animation commits). In that case there's nothing
+ // to update here; the animation's onDone will re-run cursor
+ // bookkeeping via #resetDraggableCursors().
+ if (image != null) {
+ image.classList.remove('piece-image-can-move');
+ }
+ });
+ }
+
+ #resetDraggableCursors() {
+ const isMated = () => {
+ try {
+ return this.#board.isMated();
+ } catch (e) {
+ // FIXME: happens when re-loading a game where it's your turn to play and enablePlayerMove is called
+ console.warn('error while checking if mated');
+ return false;
+ }
+ }
+
+ this.#hideAllDraggableCursors();
+
+ if (this.isPlayerMoveEnabled && !isMated()) {
+ const colorToPlay = this.#board.getColorToPlay();
+
+ this.#board
+ .listPiecePositions()
+ .filter(piecePosition => colorToPlay === piecePosition.pieceColor)
+ .map(piecePosition => piecePosition.position)
+ .filter(position => this.#board.listLegalMovesFrom(position).length > 0)
+ .forEach(position => {
+ const imageId = this.#positionToElementId('image', position);
+ const image = document.getElementById(imageId);
+ // see #hideAllDraggableCursors: image may be absent
+ // mid-animation (ID temporarily renamed).
+ if (image != null) {
+ image.classList.add('piece-image-can-move');
+ }
+ });
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {HTMLElement}
+ */
+ #locateLegalMovePlaceHolderAt(position) {
+ return document.getElementById(this.#positionToElementId('legal_move', position));
+ }
+
+ /**
+ * @param position {Position}
+ * @param prefix {string}
+ * @returns {string}
+ */
+ #positionToElementId(prefix, position) {
+ return boardPositionToElementId(this.#options.elementId, prefix.toLowerCase(), position);
+ }
+
+}
+
+/**
+ *
+ * @param boardId {string}
+ * @param prefix {string}
+ * @param position {Position}
+ */
+function boardPositionToElementId(boardId, prefix, position) {
+ return `${boardId}-${prefix.toLowerCase()}-${position.x}-${position.y}`;
+}
+
+/**
+ * @param elementId {string}
+ * @returns {Position}
+ */
+function parsePositionFromElementId(elementId) {
+ const split = elementId.split('-');
+ const x = Number(split[split.length - 2]);
+ const y = Number(split[split.length - 1]);
+ return new Position(x, y);
+}
+
+/**
+ * Adds a miniature board that appears on hover for an element.
+ * Uses {@link createWebappBoardGui} so piece images are served from the
+ * correct base URL (local server in dev, CDN in production).
+ *
+ * @param element {HTMLElement} - The element to attach hover listeners to
+ * @param gameId {string} - Unique identifier for this miniboard
+ * @param fen {string} - FEN string representing the board position
+ * @param playerColor {string} - Color to flip the board to
+ * @param lazy {boolean} - If true, only create the board on first mouseenter (default: false)
+ * @returns {HTMLElement|null} - The created miniboard div (null if lazy and not yet created)
+ */
+function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) {
+ const LEFT_MARGIN = 12;
+ const MINI_BOARD_HEIGHT = 256 / 0.9;
+
+ const miniBoardId = `mini-board-overview-${gameId}`;
+ let miniBoardDiv = null;
+
+ function createBoard() {
+ if (miniBoardDiv) return;
+
+ miniBoardDiv = document.createElement('div');
+ miniBoardDiv.id = miniBoardId;
+ miniBoardDiv.classList.add(
+ 'board-container',
+ 'mini-board-container',
+ 'mini-board-overview'
+ );
+
+ document.body.appendChild(miniBoardDiv);
+
+ const boardGui = createWebappBoardGui({
+ elementId: miniBoardId,
+ showCoordinates: false,
+ mini: true,
+ forceRenderChecks: true,
+ });
+ boardGui.loadFen(fen);
+ boardGui.flipToColor(playerColor);
+ boardGui.updateHighlightedChecks();
+ }
+
+ // Create board immediately if not lazy
+ if (!lazy) {
+ createBoard();
+ }
+
+ // listeners
+ function showMiniboard() {
+ // Create board on first hover if lazy
+ if (!miniBoardDiv) {
+ createBoard();
+ }
+
+ const gameItemRect = element.getBoundingClientRect();
+ const left = gameItemRect.right + LEFT_MARGIN + window.scrollX;
+ const top = gameItemRect.top + window.scrollY + (gameItemRect.height / 2) - (MINI_BOARD_HEIGHT / 2);
+ miniBoardDiv.style.top = `${top}px`;
+ miniBoardDiv.style.left = `${left}px`;
+ miniBoardDiv.style.display = 'block';
+ }
+
+ function hideMiniboard() {
+ if (miniBoardDiv) {
+ miniBoardDiv.style.display = 'none';
+ }
+ }
+
+ element.addEventListener('mouseenter', showMiniboard);
+ element.addEventListener('mouseleave', hideMiniboard);
+
+ return miniBoardDiv;
+}
diff --git a/webapp/src/main/resources/public/dist/0.0.4/board.css b/webapp/src/main/resources/public/dist/0.0.4/board.css
new file mode 100644
index 000000000..3750030e1
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.4/board.css
@@ -0,0 +1,418 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+:root {
+ --crosshair-margin: 5%;
+}
+
+#board-svg {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ z-index: 110;
+ pointer-events: none;
+}
+
+.board-container {
+ margin: auto;
+ aspect-ratio: 0.9;
+ background-color: #876e59;
+ padding: 4%;
+ text-align: initial;
+ touch-action: manipulation;
+ position: relative;
+ /* Matches the shared .basic-box / .info-box rule in style.css so board.css
+ renders identically when used standalone (as a library). */
+ box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em;
+ border: 2px solid #323232;
+ border-radius: 3px;
+}
+
+.safari-board-container {
+ display: flex;
+ display: -webkit-box;
+}
+
+.board-outer-container {
+ position: relative;
+ width: 80%;
+}
+
+.board-container-placeholder {
+ background-color: #808080;
+}
+
+.board-container-placeholder .visible-square,
+.board-container-placeholder .large-river {
+ background-color: #dbd8d8;
+}
+
+.board-container-placeholder .piece-image {
+ filter: grayscale(90%);
+}
+
+.board-container-placeholder .coordinates-labels {
+ color: #dbd8d8;
+}
+
+a.board-outer-container-link-mask {
+ position: absolute;
+ aspect-ratio: 0.9;
+ z-index: 150;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ width: 100%;
+}
+
+.rows {
+ height: 10%;
+}
+
+/* TODO: could be replaced by '.safari-board .rows' */
+.safari-rows {
+ height: 9%;
+ display: inline-block;
+ vertical-align: top;
+}
+
+.piece-holder {
+ float: left;
+ position: relative;
+ height: 100%;
+ aspect-ratio: 1;
+}
+
+.piece-holder .piece-image {
+ width: 95%;
+ height: 95%;
+ margin: 2.5%;
+ position: absolute;
+ border-radius: 100%;
+ box-shadow: rgba(0, 0, 0, 0.24) 0px 2px 5px;
+ z-index: 100;
+}
+
+.board-container.colorblind-friendly-black-pieces .piece-image-black {
+ filter: invert(1);
+}
+
+.piece-holder .piece-image-can-move {
+ cursor: move;
+}
+
+.piece-holder .piece-image-moving {
+ z-index: 110;
+}
+
+.crosshair-square {
+ position: absolute;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ /*background-color: rgba(0, 0, 227, 0.47);*/
+ width: 19%;
+ height: 19%;
+ z-index: 80;
+ box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.crosshair-square-bottom-right {
+ bottom: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-top-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-bottom-left {
+ bottom: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-top-width: 0;
+ border-right-width: 0;
+}
+
+.crosshair-square-top-right {
+ top: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-top-left {
+ top: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+/* 2px because it's the border size of visible-square */
+.safari-board-container .visible-square .crosshair-square-bottom-right,
+.safari-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 2px);
+}
+
+.safari-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+/* 1px because it's the border size of visible-square */
+.safari-mini-board-container .visible-square .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 1px);
+}
+
+.safari-mini-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+.visible-square, .large-river {
+ background-color: #ead1af;
+ height: 100%;
+ margin: 50%;
+ aspect-ratio: 1;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ border-right-width: 0;
+ border-bottom-width: 0;
+ position: absolute;
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.large-river {
+ aspect-ratio: 8;
+ height: 100%;
+}
+
+.visible-square-last-file,
+.large-river {
+ border-right-width: 2px;
+}
+
+.visible-square-last-row {
+ border-bottom-width: 2px;
+}
+
+.river-of-the-chu,
+.border-of-the-han {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 48%;
+}
+
+.river-of-the-chu {
+ float: left;
+}
+
+.border-of-the-han {
+ float: right;
+}
+
+.river-of-the-chu img,
+.border-of-the-han img {
+ opacity: 60%;
+ height: 70%;
+}
+
+.mini-board-container .piece-image {
+ box-shadow: none;
+}
+
+.mini-board-container .visible-square,
+.mini-board-container .large-river {
+ border-width: 1px;
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+.mini-board-container .visible-square-last-row {
+ border-bottom-width: 1px;
+}
+
+.mini-board-container .visible-square-last-file,
+.mini-board-container .large-river {
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-right {
+ border-bottom-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-left {
+ border-bottom-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-right {
+ border-top-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-left {
+ border-top-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-overview {
+ display: none;
+ position: absolute;
+ z-index: 700;
+ max-width: 236px;
+ width: 236px;
+ padding: 8px;
+}
+
+.safari-mini-board-container .safari-rows {
+ height: 10%;
+}
+
+.safari-mini-board-container {
+ height: 306px;
+ width: 276px;
+}
+
+.file-coordinates-top,
+.file-coordinates-bottom,
+.rows-coordinates-right {
+ font-size: 12px;
+ color: #ead1af;
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.file-coordinates-bottom {
+ margin-top: 100%;
+ margin-left: 50%;
+}
+
+.file-coordinates-top {
+ margin-top: -25%;
+ margin-left: 40%;
+}
+
+.rows-coordinates-right {
+ margin-left: 65%;
+ width: 100%;
+ height: 100%;
+}
+
+.legal-move-place-holder,
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-move,
+.highlighted-check,
+.highlighted-checkmate,
+.highlighted-last-move {
+ position: absolute;
+ border-radius: 100%;
+ z-index: 10;
+}
+
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-check {
+ width: 110%;
+ height: 110%;
+ margin: -5%;
+}
+
+.highlighted-checkmate,
+.highlighted-move {
+ width: 120%;
+ height: 120%;
+ margin: -10%;
+}
+
+.legal-move-place-holder {
+ width: 75%;
+ height: 75%;
+ margin: 12.5%;
+}
+
+.highlighted-last-move {
+ width: 115%;
+ height: 115%;
+ margin: -7.5%;
+}
+
+.legal-move-place-holder {
+ background-color: rgba(0, 64, 255, 0.4);
+}
+
+.selected-piece {
+ background-color: rgba(40, 229, 27, 0.7);
+}
+
+.possible-capture {
+ background-color: blueviolet;
+}
+
+.highlighted-check {
+ background-color: orange;
+}
+
+.highlighted-checkmate {
+ background-color: red;
+}
+
+.highlighted-move {
+ background-color: rgba(247, 247, 17, 0.79);
+}
+
+.highlighted-last-move {
+ border: 5px dashed rgba(13, 98, 7, 0.7);
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+/* reactive: hide river/han decorations on narrower viewports */
+@media (max-width: 1400px) {
+ .river-of-the-chu,
+ .border-of-the-han {
+ display: none;
+ }
+}
+
+/* reactive: enlarge board on small viewports */
+@media (max-width: 1000px) {
+ .board-outer-container-link-mask,
+ .board-outer-container {
+ width: 90%;
+ }
+
+ /* x2 compared to normal */
+ .safari-mini-board-container {
+ height: 612px;
+ width: 552px;
+ }
+}
diff --git a/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js b/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js
new file mode 100644
index 000000000..ce9ab435b
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.0.4/xiangqi.js
@@ -0,0 +1,1405 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see .
+ */
+
+const BOARD_WIDTH = 9;
+const BOARD_HEIGHT = 10;
+const UCI_LETTER = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
+const PIECES_CHARS = ['c', 'r', 'n', 'b', 'a', 'k', 'p'];
+
+const DEFAULT_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 0';
+
+// Piece color enum. Lives here (rather than in enums.js) so that xiangqi.js
+// can be used standalone (together with board-gui.js) without pulling in the
+// rest of the webapp's modules.
+const Color = Object.freeze({
+ RED: 'RED',
+ BLACK: 'BLACK'
+});
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isRedPiece(char) {
+ return char.length === 1 && char.toUpperCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isBlackPiece(char) {
+ return char.length === 1 && char.toLowerCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {string}
+ */
+function charToPieceColor(char) {
+ if (char.length !== 1) {
+ throw new Error('Not a single char: ' + char);
+ } else {
+ if (isRedPiece(char)) {
+ return Color.RED;
+ } else if (isBlackPiece(char)) {
+ return Color.BLACK;
+ } else {
+ throw new Error('Illegal piece ' + char);
+ }
+ }
+}
+
+function reverseColor(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return Color.BLACK;
+ case Color.BLACK:
+ return Color.RED;
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+function colorToUci(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return 'w';
+ case Color.BLACK:
+ return 'b';
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+/**
+ * Reset full count to 0
+ */
+function resetFenFullMovesCount(fen) {
+ let split = fen.split(' ');
+ return split.slice(0, split.length - 1).join(' ') + ' 0';
+}
+
+function validateStartFen(fen) {
+ let board = new Board();
+ board.loadFen(fen);
+
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ throw new Error('Start position is checkmate');
+ } else if (board.isStalemate(Color.RED) || board.isStalemate(Color.BLACK)) {
+ throw new Error('Start position is stalemate');
+ }
+}
+
+/**
+ * Add full moves count and convert to single string
+ * @param {string[]} movesAsPgn
+ */
+function toSingleLinePgn(movesAsPgn) {
+ let pgn = '';
+ for (let i = 0; i < movesAsPgn.length; i++) {
+ let move = movesAsPgn[i];
+ if (i % 2 === 0) {
+ pgn += (Math.floor(i / 2) + 1) + '. ';
+ }
+ pgn += move + ' ';
+ }
+ return pgn;
+}
+
+/**
+ * https://en.wikipedia.org/wiki/Xiangqi#System_3
+ *
+ * @param {HalfMove[]} moves
+ * @param {boolean} renderCheckIndicators
+ * @param {string} startFen
+ * @return {string[]}
+ */
+function translateMovesToPgn(moves, renderCheckIndicators = true, startFen = DEFAULT_START_FEN) {
+ let movesAsPgn = [];
+
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let pieceFrom = board.getPieceAt(move.from);
+ let pieceTarget = board.getPieceAt(move.to);
+ let newPosition = move.to.toAlgebraic();
+
+ let capture = '';
+ if (pieceTarget != null) {
+ capture = 'x';
+ }
+ let pieceLetter = pieceFrom.pieceChar.toUpperCase();
+ let formerPosition = UCI_LETTER[move.from.x];
+ let sameFile = move.from.x === move.to.x;
+ if (pieceLetter === 'K' || sameFile) {
+ formerPosition = '';
+ }
+
+ board.registerMove(move);
+
+ let checkIndicator = '';
+ if (renderCheckIndicators) {
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ checkIndicator += '#';
+ } else if (board.isInCheck(Color.RED) || board.isInCheck(Color.BLACK)) {
+ checkIndicator += '+';
+ }
+ }
+
+ let moveString = `${pieceLetter}${formerPosition}${capture}${newPosition}${checkIndicator}`;
+ movesAsPgn.push(moveString);
+ });
+
+ return movesAsPgn;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToPrefixedAlgebraic(moves, startFen = DEFAULT_START_FEN) {
+ const formattedMoves = [];
+ const board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ const pieceChar = board.getPieceAt(move.from).pieceChar.toUpperCase();
+ const moveString = `${pieceChar} ${move.toAlgebraic()}`;
+ board.registerMove(move);
+ formattedMoves.push(moveString);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param horizontalSeparator {string} can be '=' or '.'
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToWxf(moves, horizontalSeparator, startFen) {
+
+ /**
+ * File number in WXF format starts at 1 from the right hand of the player
+ *
+ * @return {number}
+ */
+ function fileAsWxf(color, file) {
+ switch (color) {
+ case Color.RED:
+ return BOARD_WIDTH - file;
+ case Color.BLACK:
+ return file + 1;
+ default:
+ throw new Error('Illegal color ' + color);
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -)
+ */
+ function verticalMoveDirectionChar(color, move) {
+ if ((color === Color.BLACK && move.from.y > move.to.y) || (color === Color.RED && move.from.y < move.to.y)) {
+ return '+';
+ } else {
+ return '-';
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -) followed by distance
+ */
+ function verticalMove(color, move) {
+ let direction = verticalMoveDirectionChar(color, move);
+ let distance = Math.abs(move.from.y - move.to.y);
+ return `${direction}${distance}`;
+ }
+
+ /**
+ * File number or sign (+ or -)
+ *
+ * @param board {Board}
+ * @param move {HalfMove}
+ * @return {string}
+ */
+ function fileNumberOrSign(board, move) {
+ let physicalPiece = board.getPieceAt(move.from);
+ let color = physicalPiece.color;
+
+ let allSimilarPiecesOnFile = board.listPiecePositions().filter(pieceAtPosition => {
+ return pieceAtPosition.piece.pieceChar === physicalPiece.pieceChar && pieceAtPosition.position.x === move.from.x
+ });
+
+ if (allSimilarPiecesOnFile.length === 2) {
+ let currentFileY;
+ let otherFileY;
+ if (move.from.y === allSimilarPiecesOnFile[0].position.y) {
+ currentFileY = allSimilarPiecesOnFile[0].position.y;
+ otherFileY = allSimilarPiecesOnFile[1].position.y;
+ } else {
+ currentFileY = allSimilarPiecesOnFile[1].position.y;
+ otherFileY = allSimilarPiecesOnFile[0].position.y;
+ }
+
+ let isOnTop = (currentFileY > otherFileY && color === Color.RED)
+ || (currentFileY < otherFileY && color === Color.BLACK);
+
+ return isOnTop ? '+' : '-';
+ }
+
+ return fileAsWxf(color, move.from.x).toString();
+ }
+
+ let formattedMoves = [];
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let physicalPiece = board.getPieceAt(move.from);
+ if (physicalPiece == null) {
+ board.printBoard();
+ throw new Error(`no piece at ${move.from.toAlgebraic()}`);
+ }
+ let color = physicalPiece.color;
+ let pieceType = physicalPiece.pieceChar.toUpperCase();
+
+ let moveStr = '';
+ switch (pieceType) {
+ case 'P':
+ case 'C':
+ case 'R':
+ case 'K':
+ if (move.isHorizontal()) {
+ let fileStr = fileNumberOrSign(board, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${fileStr}${horizontalSeparator}${newFile}`;
+ } else {
+ moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`;
+ }
+ break;
+ case 'B':
+ case 'A':
+ case 'N':
+ let currentFileStr = fileNumberOrSign(board, move);
+ let direction = verticalMoveDirectionChar(color, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${currentFileStr}${direction}${newFile}`;
+ break;
+ }
+
+ let pieceCharacter;
+
+ switch (pieceType) {
+ case 'N':
+ pieceCharacter = 'H';
+ break;
+ case 'B':
+ pieceCharacter = 'E';
+ break;
+ default:
+ pieceCharacter = pieceType;
+ break;
+ }
+
+ formattedMoves.push(`${pieceCharacter}${moveStr}`);
+ board.registerMove(move);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ *
+ * Generic version of the functions above
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesFormat(moves, moveFormat, startFen) {
+ switch (moveFormat) {
+ case MoveFormatSetting.WXF_DOT:
+ return translateMovesToWxf(moves, '.', startFen);
+ case MoveFormatSetting.WXF_EQUALS:
+ return translateMovesToWxf(moves, '=', startFen);
+ case MoveFormatSetting.PGN:
+ return translateMovesToPgn(moves, false, startFen);
+ case MoveFormatSetting.ALGEBRAIC_EN:
+ return translateMovesToPrefixedAlgebraic(moves, startFen);
+ default:
+ throw new Error('Unsupported move format ' + moveFormat);
+ }
+}
+
+/**
+ *
+ * Mainly for debugging purposes
+ *
+ * @param allMoves {HalfMove[]}
+ * @param format {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function safeTranslateMovesFormat(allMoves, format, startFen) {
+ try {
+ return translateMovesFormat(allMoves, format, startFen);
+ } catch (e) {
+ console.error(e);
+ console.info(`startFen ${startFen}`);
+ console.info(`moves [${allMoves.length}] ${allMoves.map(move => move.toAlgebraic())}`);
+ return allMoves.map(move => move.toAlgebraic());
+ }
+}
+
+/**
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {null|string}
+ */
+function translateMovesFormatTakeLast(moves, moveFormat, startFen) {
+ let translated = translateMovesFormat(moves, moveFormat, startFen);
+ if (translated.length > 0) {
+ return translated[translated.length - 1];
+ } else {
+ return null;
+ }
+}
+
+/**
+ * @param {HalfMove[]} moves
+ * @return {string}
+ */
+function exportMovesToPgnLine(moves) {
+ return toSingleLinePgn(translateMovesToPgn(moves));
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string}
+ */
+function calculateFen(moves, startFen = DEFAULT_START_FEN) {
+ const board = new Board();
+ board.loadFen(startFen);
+ moves.forEach(move => board.registerMove(move));
+ return board.outputFen();
+}
+
+class Position {
+
+ static redGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), 0);
+ static blackGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), BOARD_HEIGHT - 1);
+
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ get file() {
+ return UCI_LETTER[this.x];
+ }
+
+ /**
+ * @return {number}
+ */
+ get rank() {
+ return this.y;
+ }
+
+ existsOnBoard() {
+ return this.x >= 0 &&
+ this.y >= 0 &&
+ this.x < BOARD_WIDTH &&
+ this.y < BOARD_HEIGHT;
+ }
+
+ isInRedPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y <= 2;
+ }
+
+ isInBlackPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y >= BOARD_HEIGHT - 3;
+ }
+
+ getTop() {
+ return new Position(this.x, this.y + 1);
+ }
+
+ getBottom() {
+ return new Position(this.x, this.y - 1);
+ }
+
+ getLeft() {
+ return new Position(this.x - 1, this.y);
+ }
+
+ getRight() {
+ return new Position(this.x + 1, this.y);
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllTop() {
+ let squares = [];
+ for (let y = this.y + 1; y < BOARD_HEIGHT; y++) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllBottom() {
+ let squares = [];
+ for (let y = this.y - 1; y >= 0; y--) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllLeft() {
+ let squares = [];
+ for (let x = this.x - 1; x >= 0; x--) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllRight() {
+ let squares = [];
+ for (let x = this.x + 1; x < BOARD_WIDTH; x++) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ getTopLeft() {
+ return new Position(this.x - 1, this.y + 1);
+ }
+
+ getTopRight() {
+ return new Position(this.x + 1, this.y + 1);
+ }
+
+ getBottomLeft() {
+ return new Position(this.x - 1, this.y - 1);
+ }
+
+ getBottomRight() {
+ return new Position(this.x + 1, this.y - 1);
+ }
+
+ isEqualsTo(other) {
+ return this.x === other.x && this.y === other.y;
+ }
+
+ /**
+ * 0-based
+ */
+ toUci() {
+ return this.file + this.y;
+ }
+
+ /**
+ * 1-based
+ */
+ toAlgebraic() {
+ return this.file + (this.y + 1);
+ }
+
+ toString() {
+ return this.toUci();
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ static getAll() {
+ let positions = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ positions.push(new Position(x, y));
+ }
+ }
+ return positions;
+ }
+
+ static areEquals(p1, p2) {
+ return p1 != null && p2 != null && p1.isEqualsTo(p2);
+ }
+
+ /**
+ * @param uci {string}
+ * @return {Position}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 2) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ let x = UCI_LETTER.indexOf(uci[0])
+ if (x < 0) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ if (x >= BOARD_WIDTH) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ return new Position(x, Number(uci[1]));
+ }
+
+}
+
+class PhysicalPiece {
+
+ #pieceChar;
+ #initPosition;
+
+ /**
+ * @param pieceChar {string}
+ * @param initPosition {Position}
+ */
+ constructor(pieceChar, initPosition) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid piece ' + pieceChar);
+ }
+
+ this.#pieceChar = pieceChar;
+ this.#initPosition = initPosition;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceChar() {
+ return this.#pieceChar;
+ }
+
+ get color() {
+ return charToPieceColor(this.#pieceChar);
+ }
+
+ /**
+ * @param other {PhysicalPiece}
+ * @return {boolean}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.#pieceChar === other.#pieceChar &&
+ this.#initPosition.isEqualsTo(other.#initPosition);
+ }
+
+ toString() {
+ return `${this.#pieceChar}{${this.#initPosition.toAlgebraic()}}`;
+ }
+
+}
+
+class PieceAtPosition {
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ constructor(piece, position) {
+ this.piece = piece;
+ this.position = position;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceColor() {
+ return this.piece.color;
+ }
+
+ isColor(color) {
+ return this.pieceColor === color;
+ }
+
+ /**
+ * @param other {PieceAtPosition}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.piece.pieceChar === other.piece.pieceChar &&
+ this.position.isEqualsTo(other.position);
+ }
+
+ toString() {
+ return this.piece.pieceChar + ' at ' + this.position.toAlgebraic();
+ }
+
+}
+
+class HalfMove {
+
+ /**
+ * @type {Position}
+ */
+ from;
+
+ /**
+ * @type {Position}
+ */
+ to;
+
+ constructor(from, to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ toUci() {
+ return this.from.toUci() + this.to.toUci();
+ }
+
+ toAlgebraic() {
+ return this.from.toAlgebraic() + this.to.toAlgebraic();
+ }
+
+ isHorizontal() {
+ return this.from.y === this.to.y;
+ }
+
+ isVertical() {
+ return this.from.x === this.to.x;
+ }
+
+ toString() {
+ return this.toAlgebraic();
+ }
+
+ /**
+ * @param uci {string}
+ * @return {HalfMove}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 4) {
+ throw new Error('Incorrect move UCI: ' + uci);
+ }
+ let from = Position.parseUci(uci.substring(0, 2));
+ let to = Position.parseUci(uci.substring(2, 4));
+ return new HalfMove(from, to);
+ }
+
+ /**
+ * @param movesAsUci {string[]}
+ */
+ static parseUciMultipleMoves(movesAsUci) {
+ return movesAsUci.map(moveAsUci => HalfMove.parseUci(moveAsUci));
+ }
+
+ static areEquals(m1, m2) {
+ return m1 != null && m2 != null && Position.areEquals(m1.from, m2.from) && Position.areEquals(m1.to, m2.to);
+ }
+
+ static uciToAlgebraic(uci) {
+ return HalfMove.parseUci(uci).toAlgebraic();
+ }
+
+}
+
+class Board {
+
+ #content = [];
+ #redToPlay = true;
+ #enforceColorTurn = true;
+ #fullMovesCounts = 0;
+
+ constructor() {
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ this.#content.push([null]);
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ this.#content[x][y] = null;
+ }
+ }
+ }
+
+ // TODO: update "full moves count" accordingly
+ loadFen(fen) {
+ const split = fen.split(' ');
+ const positionsFen = split[0];
+ const gameStateFen = split[1];
+ const fenLines = positionsFen.trim().split("/")
+ if (fenLines.length !== BOARD_HEIGHT) {
+ throw new Error('Invalid FEN: wrong number of component');
+ }
+ this.clearBoard();
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ this.#loadFenLine(fenLines[BOARD_HEIGHT - 1 - y], y);
+ }
+
+ switch (gameStateFen.toLowerCase().trim()[0]) {
+ case 'w':
+ case 'r':
+ this.#redToPlay = true;
+ break;
+ case 'b':
+ this.#redToPlay = false;
+ break;
+ default:
+ throw new Error('Invalid FEN: can not determine which side plays next');
+ }
+ }
+
+ /**
+ * @param fenLine {string}
+ * @param y {number}
+ */
+ #loadFenLine(fenLine, y) {
+ let x = 0
+ for (let c = 0; fenLine.length - 1 && x < BOARD_WIDTH; c++) {
+ const char = fenLine.charAt(c);
+ if (char >= '0' && char <= '9') {
+ x += Number(char);
+ } else {
+ this.#content[x][y] = new PhysicalPiece(char, new Position(x, y));
+ x++;
+ }
+ }
+ }
+
+ outputFen() {
+ let ranks = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ ranks.push(this.#rankToFen(y));
+ }
+ return ranks.join('/') + ' ' + colorToUci(this.getColorToPlay()) + ' - - 0 ' + this.#fullMovesCounts.toString();
+ }
+
+ #rankToFen(rank) {
+ let rankPieces = Position
+ .getAll()
+ .filter(position => position.y === rank)
+ .map(position => this.getPieceAt(position));
+
+ let count = 0
+ let result = '';
+ rankPieces.forEach(piece => {
+ if (piece == null) {
+ count += 1;
+ } else {
+ if (count > 0) {
+ result += count.toString();
+ count = 0;
+ }
+ result = result.concat(piece.pieceChar);
+ }
+ });
+ if (count > 0) {
+ result += count.toString();
+ }
+
+ return result;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {PhysicalPiece|null}
+ */
+ getPieceAt(position) {
+ return this.#content[position.x][position.y];
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#content[position.x][position.y] = null;
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid char: ' + pieceChar);
+ }
+
+ // TODO: enforce rules (elephant, king, etc.)
+
+ const physicalPiece = new PhysicalPiece(pieceChar, position);
+ this.#setPieceAt(physicalPiece, position);
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ #setPieceAt(piece, position) {
+ this.#content[position.x][position.y] = piece;
+ }
+
+ /**
+ * @return {PieceAtPosition[]}
+ */
+ listPiecePositions() {
+ return Position
+ .getAll()
+ .filter(position => this.#hasPieceAt(position))
+ .map(position => new PieceAtPosition(this.getPieceAt(position), position));
+ }
+
+ /**
+ * Find all positions where a piece of type {@param piece} is located
+ * (i.e. max 5 for pawns, max 2 for knights, etc.)
+ *
+ * @param pieceChar {string}
+ * @return {Position[]}
+ */
+ listPositionsForPiece(pieceChar) {
+ let result = [];
+ Position.getAll().forEach(position => {
+ let physicalPiece = this.getPieceAt(position);
+ if (physicalPiece != null && physicalPiece.pieceChar === pieceChar) {
+ result.push(position);
+ }
+ });
+ return result;
+ }
+
+ clearBoard() {
+ this.#fullMovesCounts = 0;
+ Position.getAll().forEach(position => this.removePieceFrom(position));
+ }
+
+ /**
+ * @return {boolean} true if the piece at {@param position} matches the color that has to play now,
+ * or if "enforcedColorTurn" is disabled
+ */
+ isAllowedToPlayPieceAt(position) {
+ if (this.#enforceColorTurn) {
+ return this.#hasPieceAt(position) && this.isColorToPlay(this.getPieceAt(position));
+ } else {
+ // FIXME: here we don't check it's not empty? doesn't seem consistent
+ return true;
+ }
+ }
+
+ getColorToPlay() {
+ if (this.#redToPlay) {
+ return Color.RED;
+ } else {
+ return Color.BLACK;
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @return {boolean}
+ */
+ isColorToPlay(piece) {
+ return (piece.color === Color.RED && this.#redToPlay) || (piece.color === Color.BLACK && !this.#redToPlay);
+ }
+
+ /**
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#redToPlay = (color === Color.RED);
+ }
+
+ /**
+ * Return captured piece, or null if no piece was taken
+ */
+ registerMove(move) {
+ let piece = this.getPieceAt(move.from);
+ if (piece == null) {
+ throw new Error('No piece to move at ' + move.from);
+ } else if (this.#enforceColorTurn && !this.isColorToPlay(piece)) {
+ throw new Error(`It is ${this.getColorToPlay()}'s turn to play`);
+ } else {
+ if (this.#isPossibleMove(move)) {
+ return this.#registerMove(piece, move)
+ } else {
+ throw new Error(`Move ${move} is not possible for ${piece.pieceChar}`);
+ }
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param move {HalfMove}
+ * @return {PhysicalPiece|null} captured piece, or null if no piece was taken
+ */
+ #registerMove(piece, move) {
+ let targetPiece = this.getPieceAt(move.to);
+ this.removePieceFrom(move.from);
+ this.#setPieceAt(piece, move.to);
+ this.#redToPlay = !this.#redToPlay;
+ if (piece.color === Color.BLACK) {
+ this.#fullMovesCounts++;
+ }
+ return targetPiece;
+ }
+
+ /**
+ * @param color {string}
+ * @return {boolean}
+ */
+ isInCheck(color) {
+ let generalPosition = this.findGeneral(color).position;
+ return this
+ .#listAllMovesForColor(reverseColor(color))
+ .map(move => move.to)
+ .some(position => position.isEqualsTo(generalPosition));
+ }
+
+ isCheckmate(color) {
+ return color === this.getColorToPlay() && this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ isStalemate(color) {
+ return color === this.getColorToPlay() && !this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ #allMovesAreIllegal(color) {
+ return this.#listAllMovesForColor(color).every(move => this.#isMoveIllegal(move, color));
+ }
+
+ isMated() {
+ return this.isCheckmate(Color.RED) ||
+ this.isCheckmate(Color.BLACK) ||
+ this.isStalemate(Color.RED) ||
+ this.isStalemate(Color.BLACK);
+ }
+
+ /**
+ * @param color
+ * @return {PieceAtPosition|null}
+ */
+ findGeneral(color) {
+ return this
+ .listPiecePositions()
+ .find(piecePosition =>
+ piecePosition.piece.pieceChar.toUpperCase() === 'K' && piecePosition.isColor(color)
+ );
+ }
+
+ #isMoveIllegal(move, color) {
+ let boardCopy = this.copy();
+ if (boardCopy.#isPossibleMove(move)) {
+ boardCopy.registerMove(move);
+ return boardCopy.#areGeneralsFacing() || boardCopy.isInCheck(color);
+ } else {
+ return true;
+ }
+ }
+
+ #areGeneralsFacing() {
+ let red = this.findGeneral(Color.RED);
+ let black = this.findGeneral(Color.BLACK);
+ if (red == null || black == null) {
+ return false;
+ } else {
+ let redGeneralPosition = red.position;
+ let blackGeneralPosition = black.position;
+ return red.position.x === black.position.x && this.#noPieceOnFileBetween(redGeneralPosition, blackGeneralPosition);
+ }
+ }
+
+ #noPieceOnFileBetween(p1, p2) {
+ if (p1.x !== p2.x) {
+ return false;
+ } else {
+ for (let y = p1.y + 1; y < p2.y; y++) {
+ let position = new Position(p1.x, y);
+ if (this.#hasPieceAt(position)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ #listAllMovesForColor(color) {
+ return this
+ .listPiecePositions()
+ .filter(piecePosition => piecePosition.isColor(color))
+ .flatMap(piecePosition => this.#listAllMovesFrom(piecePosition.position));
+ }
+
+ #isPossibleMove(move) {
+ return this
+ .#listAllMovesFromAsPositions(move.from)
+ .some(targetPosition => Position.areEquals(move.to, targetPosition));
+ }
+
+ isLegalMove(move) {
+ let color = this.getColorAt(move.from);
+ if (color == null) {
+ return false;
+ } else {
+ return !this.#isMoveIllegal(move, color);
+ }
+ }
+
+ /**
+ * @returns {HalfMove[]}
+ */
+ listLegalMovesFrom(position) {
+ let color = this.getColorAt(position);
+ if (color == null) {
+ return [];
+ } else {
+ return this.#listAllMovesFrom(position).filter(move => !this.#isMoveIllegal(move, color));
+ }
+ }
+
+ /**
+ * Lists every legal move available to the given color (defaults to the
+ * color whose turn it is to play).
+ *
+ * @param color {string} optional, defaults to {@link getColorToPlay}
+ * @returns {HalfMove[]}
+ */
+ listAllLegalMoves(color = this.getColorToPlay()) {
+ const moves = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ const pos = new Position(x, y);
+ if (this.getColorAt(pos) === color) {
+ moves.push(...this.listLegalMovesFrom(pos));
+ }
+ }
+ }
+ return moves;
+ }
+
+ /**
+ * @returns Array of {@class HalfMove} one can go to from {@param from}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFrom(from) {
+ return this
+ .#listAllMovesFromAsPositions(from)
+ .map(to => new HalfMove(from, to));
+ }
+
+ /**
+ * @returns {Position[]} one can go to from {@param position}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFromAsPositions(position) {
+ switch (this.getPieceAt(position).pieceChar.toLowerCase()) {
+ case 'c':
+ return this.#listMovesForCannon(position);
+ case 'r':
+ return this.#listMovesForChariot(position);
+ case 'n':
+ return this.#listMovesForHorse(position);
+ case 'b':
+ return this.#listMovesForElephant(position);
+ case 'a':
+ return this.#listMovesForAdvisor(position);
+ case 'k':
+ return this.#listMovesForGeneral(position);
+ case 'p':
+ return this.#listMovesForSoldier(position);
+ default:
+ throw new Error('Not implemented for ' + this.getPieceAt(position).pieceChar)
+ }
+ }
+
+ #listMovesForCannon(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForCannon(position, allTargetPosition) {
+ let result = [];
+ let foundPivot = false;
+
+ for (let i = 0; i < allTargetPosition.length; i++) {
+ let targetPosition = allTargetPosition[i];
+ if (!foundPivot) {
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else {
+ foundPivot = true;
+ }
+ } else {
+ if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForChariot(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForChariot(position, allTargetPositions) {
+ let result = [];
+
+ for (let i = 0; i < allTargetPositions.length; i++) {
+ let targetPosition = allTargetPositions[i];
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForHorse(position) {
+ let result = [];
+ let top = position.getTop();
+ let bottom = position.getBottom();
+ let left = position.getLeft();
+ let right = position.getRight();
+ if (top.existsOnBoard() && !this.#hasPieceAt(top)) {
+ result.push(top.getTopLeft());
+ result.push(top.getTopRight());
+ }
+ if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) {
+ result.push(bottom.getBottomLeft());
+ result.push(bottom.getBottomRight());
+ }
+ if (left.existsOnBoard() && !this.#hasPieceAt(left)) {
+ result.push(left.getTopLeft());
+ result.push(left.getBottomLeft());
+ }
+ if (right.existsOnBoard() && !this.#hasPieceAt(right)) {
+ result.push(right.getTopRight());
+ result.push(right.getBottomRight());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(targetPosition, position)
+ );
+ }
+
+ #listMovesForElephant(position) {
+ let result = [];
+ let topLeft = position.getTopLeft();
+ let topRight = position.getTopRight();
+ let bottomRight = position.getBottomRight();
+ let bottomLeft = position.getBottomLeft();
+
+ if (topLeft.existsOnBoard() && !this.#hasPieceAt(topLeft)) {
+ result.push(topLeft.getTopLeft());
+ }
+ if (topRight.existsOnBoard() && !this.#hasPieceAt(topRight)) {
+ result.push(topRight.getTopRight());
+ }
+ if (bottomRight.existsOnBoard() && !this.#hasPieceAt(bottomRight)) {
+ result.push(bottomRight.getBottomRight());
+ }
+ if (bottomLeft.existsOnBoard() && !this.#hasPieceAt(bottomLeft)) {
+ result.push(bottomLeft.getBottomLeft());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() &&
+ !this.containSameColors(position, targetPosition) &&
+ !this.#areOnOppositeSidesOfTheRiver(position, targetPosition)
+ );
+ }
+
+ #listMovesForAdvisor(position) {
+ let result = [];
+ result.push(position.getTopLeft());
+ result.push(position.getTopRight());
+ result.push(position.getBottomRight());
+ result.push(position.getBottomLeft());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #listMovesForGeneral(position) {
+ let result = [];
+ result.push(position.getTop());
+ result.push(position.getBottom());
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #filterMovesInItsPalace(position, allTargetPosition) {
+ if (this.#hasRedPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInRedPalace() && !this.#hasRedPieceAt(target)
+ );
+ } else if (this.#hasBlackPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInBlackPalace() && !this.#hasBlackPieceAt(target)
+ );
+ } else {
+ console.error("we should never pass here")
+ return [];
+ }
+ }
+
+ #listMovesForSoldier(position) {
+ let result = [];
+ if (this.#hasRedPieceAt(position)) {
+ // is red
+ result.push(position.getTop());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.redGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else if (this.#hasBlackPieceAt(position)) {
+ // is black
+ result.push(position.getBottom());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.blackGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else {
+ console.warn("we should never pass here")
+ return [];
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition)
+ );
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {null|string}
+ */
+ getColorAt(position) {
+ let piece = this.getPieceAt(position);
+ if (piece != null) {
+ return piece.color;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasPieceAt(position) {
+ return this.#content[position.x][position.y] != null;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasRedPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.RED, position);
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasBlackPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.BLACK, position);
+ }
+
+ #hasPieceOfColorAt(color, position) {
+ let piece = this.getPieceAt(position);
+ return piece != null && piece.color === color;
+ }
+
+ #areOnOppositeSidesOfTheRiver(p1, p2) {
+ return (p1.y <= 4 && p2.y >= 5) || (p2.y <= 4 && p1.y >= 5);
+ }
+
+ containSameColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasRedPieceAt(p2))
+ || (this.#hasBlackPieceAt(p1) && this.#hasBlackPieceAt(p2));
+ }
+
+ containOppositeColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasBlackPieceAt(p2))
+ || (this.#hasRedPieceAt(p2) && this.#hasBlackPieceAt(p1));
+ }
+
+ printBoardToLines() {
+ let lines = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ let row = '';
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let piece = this.getPieceAt(new Position(x, y));
+ if (piece == null) {
+ row += ' ';
+ } else {
+ row += piece.pieceChar + ' ';
+ }
+ }
+ lines.push(row);
+ }
+ return lines;
+ }
+
+ printBoard() {
+ let oneLine = '';
+ this.printBoardToLines().forEach(line => oneLine += line + '\n');
+ console.log(oneLine);
+ }
+
+ /**
+ * @return {Board}
+ */
+ copy() {
+ let copy = new Board();
+ copy.loadFen(this.outputFen());
+ return copy
+ }
+
+}
diff --git a/webapp/src/main/resources/public/dist/0.1.0/board-gui.js b/webapp/src/main/resources/public/dist/0.1.0/board-gui.js
new file mode 100644
index 000000000..976c721c5
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.1.0/board-gui.js
@@ -0,0 +1,1870 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see .
+ */
+
+// Small DOM helpers and constants, inlined here so this file is usable on its
+// own (only requires xiangqi.js, which provides the `Color` enum). Previously
+// imported from utils.js / ui.js. For helpers that may also be defined by
+// those modules when loaded as part of the full webapp, we only define them
+// if the host page hasn't already loaded those modules, to avoid clobbering
+// existing definitions.
+if (typeof buildImg === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildImg = function (src, className = null) {
+ const img = document.createElement('img');
+ img.src = src;
+ if (className != null) img.className = className;
+ return img;
+ };
+}
+
+if (typeof htmlCollectionToArray === 'undefined') {
+ // eslint-disable-next-line no-var
+ var htmlCollectionToArray = function (htmlCollection) {
+ const arr = [];
+ for (let i = 0; i < htmlCollection.length; i++) {
+ arr.push(htmlCollection[i]);
+ }
+ return arr;
+ };
+}
+
+if (typeof buildDivWithClass === 'undefined') {
+ // eslint-disable-next-line no-var
+ var buildDivWithClass = function (className) {
+ const div = document.createElement('div');
+ div.className = className;
+ return div;
+ };
+}
+
+// Mapping from FEN piece char to piece image file name. Lives here (rather
+// than in ui.js) so that board-gui.js can be used standalone.
+const pieceImageNames = new Map();
+pieceImageNames.set('K', 'red_general.png');
+pieceImageNames.set('k', 'black_general.png');
+pieceImageNames.set('A', 'red_advisor.png');
+pieceImageNames.set('a', 'black_advisor.png');
+pieceImageNames.set('B', 'red_elephant.png');
+pieceImageNames.set('b', 'black_elephant.png');
+pieceImageNames.set('N', 'red_horse.png');
+pieceImageNames.set('n', 'black_horse.png');
+pieceImageNames.set('C', 'red_cannon.png');
+pieceImageNames.set('c', 'black_cannon.png');
+pieceImageNames.set('R', 'red_chariot.png');
+pieceImageNames.set('r', 'black_chariot.png');
+pieceImageNames.set('P', 'red_soldier.png');
+pieceImageNames.set('p', 'black_soldier.png');
+// Manchu super-chariot (combines chariot + horse + cannon powers), red only, displayed as chariot
+pieceImageNames.set('M', 'red_chariot.png');
+
+const diagonalDescending = ' ';
+const diagonalRising = ' ';
+
+const diagonalDescendingMini = ' ';
+const diagonalRisingMini = ' ';
+
+const PRIMARY_ARROW_COLOR = 'rgb(27, 181, 29)';
+const SECONDARY_ARROW_COLOR = 'rgb(62, 56, 219)';
+
+const ARROW_MARKERS = `
+
+
+
+
+
+
+
+ `;
+
+const bottomRightCrosshairs = [
+ new Position(0, 8), new Position(6, 8),
+ new Position(1, 7), new Position(3, 7), new Position(5, 7), new Position(7, 7),
+ new Position(1, 4), new Position(3, 4), new Position(5, 4), new Position(7, 4),
+ new Position(0, 3), new Position(6, 3),
+];
+
+const bottomLeftCrosshairs = [
+ new Position(1, 8), new Position(7, 8),
+ new Position(0, 7), new Position(2, 7), new Position(4, 7), new Position(6, 7),
+ new Position(0, 4), new Position(2, 4), new Position(4, 4), new Position(6, 4),
+ new Position(1, 3), new Position(7, 3),
+];
+
+const topRightCrosshairs = [
+ new Position(0, 7), new Position(6, 7),
+ new Position(1, 6), new Position(3, 6), new Position(5, 6), new Position(7, 6),
+ new Position(1, 3), new Position(3, 3), new Position(5, 3), new Position(7, 3),
+ new Position(0, 2), new Position(6, 2),
+];
+
+const topLeftCrosshairs = [
+ new Position(1, 7), new Position(7, 7),
+ new Position(0, 6), new Position(2, 6), new Position(4, 6), new Position(6, 6),
+ new Position(0, 3), new Position(2, 3), new Position(4, 3), new Position(6, 3),
+ new Position(1, 2), new Position(7, 2)
+];
+
+const EngineArrowType = Object.freeze({
+ PRIMARY: 'PRIMARY',
+ SECONDARY: 'SECONDARY'
+});
+
+/**
+ * Orientation of the board coordinate labels.
+ * Use `null` (not part of this enum) to hide the coordinates entirely.
+ */
+const CoordinatesOrientation = Object.freeze({
+ WXF: 'WXF',
+ ALGEBRAIC: 'ALGEBRAIC',
+});
+
+// Chinese numerals for files 1..9, used to label files in WXF mode when the
+// {@link FileNumbersStyle} setting calls for Chinese numerals on that side.
+const CHINESE_FILE_DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九'];
+
+/**
+ * How file numbers are rendered around the board in WXF mode. Only affects the
+ * file-number labels; the algebraic orientation (a..i letters) is unaffected.
+ */
+const FileNumbersStyle = Object.freeze({
+ /** Arabic numerals (1..9) on both sides of the board. */
+ ARABIC_BOTH: 'ARABIC_BOTH',
+ /** Chinese numerals (一..九) on both sides of the board. */
+ CHINESE_BOTH: 'CHINESE_BOTH',
+ /** Chinese numerals on red's side; Arabic numerals on black's side (default). */
+ CHINESE_RED_ONLY: 'CHINESE_RED_ONLY',
+ /** Chinese numerals on black's side; Arabic numerals on red's side. */
+ CHINESE_BLACK_ONLY: 'CHINESE_BLACK_ONLY',
+ /** Chinese numerals on the bottom (lower) side of the screen; Arabic on the top side. */
+ CHINESE_LOWER_ONLY: 'CHINESE_LOWER_ONLY',
+ /** Chinese numerals on the top side of the screen; Arabic on the bottom side. */
+ CHINESE_TOP_ONLY: 'CHINESE_TOP_ONLY',
+ DEFAULT: 'CHINESE_RED_ONLY',
+});
+
+/**
+ * Visual style of the board pieces. Used to pick the corresponding image folder
+ * (under `${assetsBaseUrl}/images/pieces//`).
+ */
+const PieceStyleSetting = Object.freeze({
+ TRADITIONAL: 'TRADITIONAL',
+ ROMANIZED_ROUNDED: 'ROMANIZED_ROUNDED',
+ DEFAULT: 'TRADITIONAL',
+});
+
+/**
+ * @typedef {Object} BoardGuiOptions
+ * @property {string} [elementId] - id of the container element
+ * @property {boolean} [showCoordinates] - whether to reserve space for file/rank coordinates
+ * @property {string|null} [coordinatesOrientation] - one of {@link CoordinatesOrientation} or `null` to
+ * hide the labels (space is still reserved when
+ * `showCoordinates` is true). The caller is in
+ * charge of resolving any user preference (e.g. cookies).
+ * @property {boolean} [mini] - whether this is a mini (thumb) board
+ * @property {boolean} [forceRenderChecks] - render checks even on mini boards
+ * @property {boolean} [svg] - enable the SVG overlay (used for engine arrows)
+ * @property {string} [assetsBaseUrl] - base URL prepended to every static asset path
+ * (images, audio). Default: `https://elephantchess.io`.
+ * Pass an empty string to use relative paths (e.g. when
+ * serving the assets from the current host on localhost).
+ * @property {string} [pieceStyle] - one of {@link PieceStyleSetting}; selects the piece
+ * image folder.
+ * @property {boolean} [colorblindFriendlyBlackPieces] - if true, black piece images get an invert
+ * CSS filter for improved contrast.
+ * @property {string} [fileNumbersStyle] - one of {@link FileNumbersStyle}; selects how
+ * file numbers are rendered in WXF mode.
+ */
+
+/** @type {Readonly>} */
+const DEFAULT_BOARD_GUI_OPTIONS = Object.freeze({
+ elementId: 'board-container',
+ showCoordinates: true,
+ coordinatesOrientation: 'WXF',
+ mini: false,
+ forceRenderChecks: false,
+ svg: false,
+ assetsBaseUrl: 'https://cdn.elephantchess.io/static',
+ pieceStyle: PieceStyleSetting.DEFAULT,
+ colorblindFriendlyBlackPieces: false,
+ fileNumbersStyle: FileNumbersStyle.DEFAULT,
+});
+
+class BoardGui {
+
+ #board = new Board();
+ #boardContainer;
+ #currentShowingLegalMovesFor = null;
+ #selectedPiecePosition = null;
+ #flippedRed = true; // if true, oriented toward the RED player
+ #afterMoveListeners = [];
+ #afterDrawPositionsListeners = [];
+ #afterFlipListeners = [];
+
+ /** @type {HTMLAudioElement} */
+ #clickSound;
+
+ // when false, player won't be able to make a move
+ #isPlayerMoveEnabled = true;
+
+ /**
+ * @type {Readonly>}
+ */
+ #options;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #primaryEngineArrow = null;
+
+ /**
+ * @type {HalfMove|null}
+ */
+ #secondaryEngineArrow = null;
+
+ #isSafari = false;
+
+ /**
+ * @param {BoardGuiOptions} [options]
+ */
+ constructor(options = {}) {
+ this.#isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ this.#options = Object.freeze({...DEFAULT_BOARD_GUI_OPTIONS, ...options});
+
+ this.#clickSound = new Audio(`${this.#options.assetsBaseUrl}/audio/rclick-13693.mp3`);
+
+ this.#boardContainer = document.getElementById(this.#options.elementId);
+ this.#renderColorblindFriendlyBlackPiecesSetting(this.#options.colorblindFriendlyBlackPieces);
+ this.#drawBoard();
+ this.#drawPieces(); // FIXME: useful?
+
+ const boardGui = this;
+
+ document
+ .getElementsByTagName('html')
+ .item(0)
+ .addEventListener('click', (e) => {
+ if (!boardGui.#boardContainer.contains(e.target)) {
+ boardGui.#hideAllPiecePlaceHolders();
+ }
+ });
+
+ if (this.#options.svg) {
+ window.onresize = function () {
+ boardGui.#renderSvg();
+ };
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Draw the position described by {@code fen}.
+ *
+ * When {@code animate} is true, the transition from the currently displayed
+ * position to the target position is animated: unchanged pieces are left in
+ * place, pieces of the same type are paired between the current and target
+ * positions and slide from their source square to their destination square,
+ * leftover current pieces fade out, and target pieces fade in - all
+ * simultaneously. This works for any diff size (not just a single move),
+ * similar to what lichess does.
+ *
+ * @param fen {string}
+ * @param animate {boolean}
+ */
+ loadFen(fen, animate = false) {
+ if (this.isInPlaceHolderMode()) {
+ console.warn('Can not load FEN when in placeholder mode');
+ return;
+ }
+
+ if (animate) {
+ this.#drawPositionAnimated(fen);
+ } else {
+ this.#drawPositionNoAnimation(fen);
+ }
+ }
+
+ #drawPositionNoAnimation(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+ this.#unDrawAllPieces();
+ this.#board.loadFen(targetFen);
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ * Compute the target position (via a temporary board so we don't mutate the
+ * current {@link Board} model before we're ready) and diff it against the
+ * currently displayed pieces to build the animation plan.
+ *
+ * @param targetFen {string}
+ */
+ #drawPositionAnimated(targetFen) {
+ this.#hideAllPiecePlaceHolders();
+ this.#hideHighlightedLastMove();
+
+ // compute the target piece layout via a temp board (no DOM side-effects,
+ // and no mutation of the real model yet)
+ const targetBoard = new Board();
+ targetBoard.loadFen(targetFen);
+ const targetPieces = targetBoard.listPiecePositions();
+
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ // commit the new board state in the model
+ this.#board.loadFen(targetFen);
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#afterDrawPositionsListeners.forEach(listener => listener());
+ });
+ }
+
+ outputFen() {
+ return this.#board.outputFen();
+ }
+
+ /**
+ * Returns a copy of the underlying (non-GUI) {@link Board}, useful when
+ * callers need to inspect/query the board model without touching the DOM.
+ *
+ * A copy is returned rather than the internal instance to preserve
+ * encapsulation (so callers can't mutate the BoardGui's state directly).
+ *
+ * @return {Board}
+ */
+ get board() {
+ return this.#board.copy();
+ }
+
+ /**
+ * Change which color is to play next (doesn't alter the pieces on the board).
+ *
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#board.forceColorToPlay(color);
+ }
+
+ get isPlayerMoveEnabled() {
+ return this.#isPlayerMoveEnabled;
+ }
+
+ set isPlayerMoveEnabled(value) {
+ if (value) {
+ this.enablePlayerMove();
+ } else {
+ this.disablePlayerMove();
+ }
+ }
+
+ disablePlayerMove() {
+ this.#isPlayerMoveEnabled = false;
+ this.#hideAllPiecePlaceHolders();
+ this.#hideAllDraggableCursors();
+ }
+
+ enablePlayerMove() {
+ this.#isPlayerMoveEnabled = true;
+ this.#resetDraggableCursors();
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterMoveListener(listener) {
+ this.#afterMoveListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function}
+ */
+ addAfterDrawPositionsListener(listener) {
+ this.#afterDrawPositionsListeners.push(listener);
+ }
+
+ /**
+ * @param listener {function(string)}
+ */
+ addAfterFlipListener(listener) {
+ this.#afterFlipListeners.push(listener);
+ }
+
+ clearAllAfterMovesListeners() {
+ this.#afterMoveListeners = [];
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param highLastMove {boolean} if true, the last move will be highlighted with a green dashed circle
+ * @param afterMoveCallback {function}
+ */
+ registerOpponentMove(move, highLastMove, afterMoveCallback) {
+ this.#hideHighlightedLastMove();
+ this.#animateMoveViaDiff(move, () => {
+ if (highLastMove) {
+ this.highlightLastMove(move);
+ }
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ this.#clickSound
+ .play()
+ .catch(e => {
+ // ignored, spam error in console in dev
+ });
+ if (afterMoveCallback != null) {
+ afterMoveCallback()
+ }
+ });
+ }
+
+ /**
+ * Animate the DOM pieces from the currently displayed position (as
+ * described by {@link #board}) to {@code targetPieces}. Does NOT mutate the
+ * underlying board model; the caller is expected to update the model in
+ * {@code onDone}.
+ *
+ * Diff strategy (mirrors lichess/chessground):
+ * - pieces of the same pieceChar on the same square: leave image alone
+ * - current-board misplacements and target-board misplacements of the
+ * same pieceChar: paired greedily by shortest distance and animated
+ * with a CSS transform transition
+ * - unpaired leftover current-board misplacements: faded out
+ * - unpaired leftover target-board misplacements: faded in
+ *
+ * @param targetPieces {PieceAtPosition[]}
+ * @param durationMs {number}
+ * @param onDone {function}
+ */
+ #animatePiecesTo(targetPieces, durationMs, onDone) {
+ const currentPieces = this.#board.listPiecePositions();
+
+ const posKey = p => `${p.toUci()}`;
+ const currentMap = new Map(currentPieces.map(pp => [posKey(pp.position), pp]));
+ const targetMap = new Map(targetPieces.map(pp => [posKey(pp.position), pp]));
+
+ // pieces currently drawn on the board that don't match the target
+ // position (either the target expects a different piece on that
+ // square, or no piece at all). Each will be either animated to a
+ // square in targetBoardMisplacements or faded out.
+ /** @type {PieceAtPosition[]} */
+ const currentBoardMisplacements = [];
+ for (const [positionKey, currentPp] of currentMap) {
+ const targetPp = targetMap.get(positionKey);
+ if (targetPp && targetPp.piece.pieceChar === currentPp.piece.pieceChar) {
+ // same piece on same square: leave image alone
+ targetMap.delete(positionKey);
+ } else {
+ currentBoardMisplacements.push(currentPp);
+ }
+ }
+
+ // pieces required by the target position that aren't already correctly
+ // drawn on the current board (the square is empty or holds a different
+ // piece). Each will be either the destination of an animated move from
+ // currentBoardMisplacements or faded in as a new image.
+ /** @type {PieceAtPosition[]} */
+ const targetBoardMisplacements = Array.from(targetMap.values());
+
+ // pair movers greedily by closest same-pieceChar distance
+ const calculateDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+ const candidatePairs = [];
+ currentBoardMisplacements.forEach((c, i) => {
+ targetBoardMisplacements.forEach((t, j) => {
+ if (t.piece.pieceChar === c.piece.pieceChar) {
+ candidatePairs.push({i, j, distance: calculateDistance(c.position, t.position)});
+ }
+ });
+ });
+ candidatePairs.sort((a, b) => a.distance - b.distance);
+
+ // indices into currentBoardMisplacements / targetBoardMisplacements that
+ // have already been claimed by an animated move, so we don't pair the
+ // same piece twice while walking the distance-sorted candidate list.
+ /** @type {Set} */
+ const usedCurrent = new Set();
+ /** @type {Set} */
+ const usedTarget = new Set();
+ const animatedMoves = [];
+ for (const {i, j} of candidatePairs) {
+ if (!usedCurrent.has(i) && !usedTarget.has(j)) {
+ usedCurrent.add(i);
+ usedTarget.add(j);
+ animatedMoves.push({
+ from: currentBoardMisplacements[i].position,
+ to: targetBoardMisplacements[j].position,
+ pieceChar: currentBoardMisplacements[i].piece.pieceChar,
+ });
+ }
+ }
+ const toRemove = currentBoardMisplacements.filter((_, i) => !usedCurrent.has(i));
+ const toAdd = targetBoardMisplacements.filter((_, j) => !usedTarget.has(j));
+
+ // nothing to animate: bail out early
+ if (animatedMoves.length === 0 && toRemove.length === 0 && toAdd.length === 0) {
+ onDone();
+ return;
+ }
+
+ const transitionStr = `transform ${durationMs}ms ease, opacity ${durationMs}ms ease`;
+ const getSquareRect = pos => document
+ .getElementById(this.#positionToElementId('square', pos))
+ .getBoundingClientRect();
+
+ const cleanups = [];
+
+ // animate movers: translate the source-square image toward the target square
+ animatedMoves.forEach(animatedMove => {
+ const img = document.getElementById(this.#positionToElementId('image', animatedMove.from));
+ if (img == null) return;
+ const fromRect = getSquareRect(animatedMove.from);
+ const toRect = getSquareRect(animatedMove.to);
+ const dx = toRect.left - fromRect.left;
+ const dy = toRect.top - fromRect.top;
+
+ // rename id so it doesn't conflict with drawPieceAt on the target square
+ img.id = `animating-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ // must stay above .crosshair-square (z-index: 80) and resting
+ // pieces (z-index: 100) so the moving piece isn't painted under
+ // crosshairs of neighboring squares it flies over
+ img.style.zIndex = '110';
+ img.style.pointerEvents = 'none';
+
+ // trigger on next frame so the transition actually runs
+ requestAnimationFrame(() => {
+ img.style.transform = `translate(${dx}px, ${dy}px)`;
+ });
+
+ cleanups.push(() => img.remove());
+ });
+
+ // fade out leftover current pieces (captures / removals)
+ toRemove.forEach(rm => {
+ const img = document.getElementById(this.#positionToElementId('image', rm.position));
+ if (img == null) return;
+ img.id = `fading-${Math.random().toString(36).slice(2)}`;
+ img.style.transition = transitionStr;
+ img.style.pointerEvents = 'none';
+ requestAnimationFrame(() => {
+ img.style.opacity = '0';
+ });
+ cleanups.push(() => img.remove());
+ });
+
+ // fade in leftover target pieces (newly placed on a previously empty square)
+ const addedImages = [];
+ toAdd.forEach(add => {
+ this.#drawPieceAt(add.piece.pieceChar, add.position);
+ const img = document.getElementById(this.#positionToElementId('image', add.position));
+ if (img != null) {
+ img.style.opacity = '0';
+ img.style.transition = transitionStr;
+ addedImages.push(img);
+ requestAnimationFrame(() => {
+ img.style.opacity = '1';
+ });
+ }
+ });
+
+ setTimeout(() => {
+ // drop transient animation images
+ cleanups.forEach(c => c());
+
+ // draw the final image at each move's target square
+ animatedMoves.forEach(mv => {
+ const existing = document.getElementById(this.#positionToElementId('image', mv.to));
+ if (existing != null) existing.remove();
+ this.#drawPieceAt(mv.pieceChar, mv.to);
+ });
+
+ // clear transient styles on faded-in images
+ addedImages.forEach(img => {
+ img.style.transition = '';
+ img.style.opacity = '';
+ });
+
+ onDone();
+ this.#forceSafariLayoutRefresh();
+ }, durationMs + 20);
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param animate {boolean}
+ */
+ registerMoveIfLegal(move, animate = true) {
+ if (!this.#board.isLegalMove(move)) {
+ console.log(move + ' is not a legal move');
+ return;
+ }
+
+ const afterCommit = () => {
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+ for (let i = 0; i < this.#afterMoveListeners.length; i++) {
+ this.#afterMoveListeners[i](move);
+ }
+ };
+
+ if (animate) {
+ // the diff-based animator will commit the model via its onDone
+ this.#animateMoveViaDiff(move, afterCommit);
+ } else {
+ const piece = this.#board.getPieceAt(move.from);
+ this.#board.registerMove(move);
+ this.#drawMove(piece.pieceChar, move);
+ afterCommit();
+ }
+ }
+
+ /**
+ * Animate a single move by delegating to the generic diff-based animator
+ * {@link #animatePiecesTo}. The target position is computed on a copy of
+ * the current board so the real model is only mutated once the animation
+ * completes. This replaces the old xiangqi-shape-aware setInterval based
+ * {@code #animateMove}.
+ *
+ * @param move {HalfMove}
+ * @param onDone {function}
+ */
+ #animateMoveViaDiff(move, onDone) {
+ const targetBoard = this.#board.copy();
+ targetBoard.registerMove(move);
+ const targetPieces = targetBoard.listPiecePositions();
+ this.#animatePiecesTo(targetPieces, 200, () => {
+ this.#board.registerMove(move);
+ onDone();
+ });
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ this.#board.addPieceAt(pieceChar, position, enforcePlacementRules);
+ this.#unDrawPiece(position);
+ this.#drawPieceAt(pieceChar, position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#board.removePieceFrom(position);
+ this.#unDrawPiece(position);
+ }
+
+ // FIXME: feels like this could be private? encapsulated?
+ updateHighlightedChecks() {
+ this.#hideAllCheckHighlights();
+
+ if (!this.#options.mini || this.#options.forceRenderChecks) {
+ const redGeneral = this.#board.findGeneral(Color.RED);
+ const blackGeneral = this.#board.findGeneral(Color.BLACK);
+
+ // generals may be absent (e.g. when flip() is called before any
+ // FEN has been loaded, on an empty board); nothing to highlight.
+ if (redGeneral == null || blackGeneral == null) {
+ return;
+ }
+
+ if (this.#board.isInCheck(Color.RED)) {
+ if (this.#board.isCheckmate(Color.RED)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(redGeneral.position);
+ } else {
+ this.#highlightCheck(redGeneral.position);
+ }
+ }
+
+ if (this.#board.isInCheck(Color.BLACK)) {
+ if (this.#board.isCheckmate(Color.BLACK)) {
+ this.disablePlayerMove();
+ this.#hideHighlightedLastMove();
+ this.#highlightCheckMate(blackGeneral.position);
+ } else {
+ this.#highlightCheck(blackGeneral.position);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ */
+ highlightLastMove(move) {
+ this.#highlightWithClass(move.from, 'highlighted-last-move');
+ this.#highlightWithClass(move.to, 'highlighted-last-move');
+ }
+
+ #hideAllCheckHighlights() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-check', 'highlighted-checkmate'));
+ }
+
+ #hideHighlightedLastMove() {
+ Position
+ .getAll()
+ .map(position => this.#locateLegalMovePlaceHolderAt(position))
+ .map(placeHolder => placeHolder.classList)
+ .forEach(classList => classList.remove('highlighted-last-move'));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheck(position) {
+ this.#highlightWithClass(position, 'highlighted-check');
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #highlightCheckMate(position) {
+ this.#highlightWithClass(position, 'highlighted-checkmate');
+ }
+
+ /**
+ * @param position {Position}
+ * @param className {string}
+ */
+ #highlightWithClass(position, className) {
+ this.#locateLegalMovePlaceHolderAt(position).classList.add(className);
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ isInPlaceHolderMode() {
+ return this.#boardContainer.classList.contains('board-container-placeholder');
+ }
+
+ enablePlaceholderMode() {
+ this.#boardContainer.classList.add('board-container-placeholder');
+ }
+
+ disablePlaceholderMode() {
+ this.#boardContainer.classList.remove('board-container-placeholder');
+ }
+
+ /**
+ * Clear the SVG layer, which atm only render analytics arrows.
+ */
+ clearSvg() {
+ if (this.#options.svg) {
+ const svg = document.getElementById('board-svg');
+ svg.innerHTML = '';
+ svg.innerHTML += ARROW_MARKERS;
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ */
+ addEngineArrow(move, type) {
+ if (this.#options.svg) {
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ this.#primaryEngineArrow = move;
+ break;
+ case EngineArrowType.SECONDARY:
+ this.#secondaryEngineArrow = move;
+ break;
+ }
+
+ this.#renderSvg();
+ }
+ }
+
+ #renderSvg() {
+ if (this.#options.svg) {
+ this.clearSvg();
+ const svg = document.getElementById('board-svg');
+ if (this.#primaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#primaryEngineArrow, EngineArrowType.PRIMARY));
+ }
+ if (this.#secondaryEngineArrow != null) {
+ svg.append(...this.#buildMoveArrow(this.#secondaryEngineArrow, EngineArrowType.SECONDARY));
+ }
+ }
+ }
+
+ /**
+ * @param move {HalfMove}
+ * @param type {string}
+ * @returns {SVGGeometryElement[]}
+ */
+ #buildMoveArrow(move, type) {
+ function isSmallMove(move) {
+ return (move.isVertical() && Math.abs(move.to.y - move.from.y) <= 1) ||
+ (move.isHorizontal() && Math.abs(move.to.x - move.from.x) <= 1)
+ }
+
+ function isKnightMove(move) {
+ const dx = Math.abs(move.to.x - move.from.x);
+ const dy = Math.abs(move.to.y - move.from.y);
+ return (dx === 1 && dy === 2) || (dx === 2 && dy === 1);
+ }
+
+ const boardBounds = this.#boardContainer.getBoundingClientRect();
+
+ const square1 = document.getElementById(this.#positionToElementId('square', move.from));
+ const square2 = document.getElementById(this.#positionToElementId('square', move.to));
+ const bound1 = square1.getBoundingClientRect();
+ const bound2 = square2.getBoundingClientRect();
+
+ const x1 = (bound1.left + bound1.width / 2) - boardBounds.left;
+ const y1 = (bound1.top + bound1.height / 2) - boardBounds.top;
+ const x2 = (bound2.left + bound2.width / 2) - boardBounds.left;
+ const y2 = (bound2.top + bound2.height / 2) - boardBounds.top;
+
+ // stroke width scales with the square size so the arrow looks consistent across viewports
+ // (a fixed thick stroke is proportionally too fat on smaller boards, making the elbow look off-centered)
+ const strokeWidth = Math.min(16, bound1.width * 0.16);
+
+ let pathD, midpointX, midpointY;
+
+ if (isKnightMove(move)) {
+ // draw an elbowed arrow matching the horse's actual movement:
+ // segment 1: orthogonal (straight line, 1 square) — the horse's "leg"
+ // segment 2: diagonal (1 square) — the horse's diagonal step
+ const sourceReduction = bound1.width * 0.40;
+ // the arrow-head marker (path "M0,0 V4 L2,2", refX=0.1) tips at 1.9 marker units past the
+ // path endpoint, and markerUnits defaults to strokeWidth — so to make the tip land exactly
+ // on the destination intersection, we shorten the destination end by 1.9 * strokeWidth.
+ const destReduction = strokeWidth * 1.9;
+ const dxPx = x2 - x1;
+ const dyPx = y2 - y1;
+
+ // Returns the point shortened by `r` pixels along the line from (px,py) toward (tx,ty)
+ function shortenToward(px, py, tx, ty, r) {
+ const dx = tx - px;
+ const dy = ty - py;
+ const d = Math.sqrt(dx * dx + dy * dy);
+ return [px + (dx / d) * r, py + (dy / d) * r];
+ }
+
+ let x1Prime, y1Prime, x2Prime, y2Prime, elbowX, elbowY;
+
+ const dyBoardSquares = Math.abs(move.to.y - move.from.y);
+
+ if (dyBoardSquares === 2) {
+ // vertical-dominant: horse moves 2 squares vertically then 1 diagonally
+ // elbow is exactly 1 square-height above/below the source, derived from the actual pixel displacement
+ elbowX = x1;
+ elbowY = y1 + dyPx / 2;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ } else {
+ // horizontal-dominant: horse moves 2 squares horizontally then 1 diagonally
+ // elbow is exactly 1 square-width left/right of the source, derived from the actual pixel displacement
+ elbowX = x1 + dxPx / 2;
+ elbowY = y1;
+
+ [x1Prime, y1Prime] = shortenToward(x1, y1, elbowX, elbowY, sourceReduction);
+ [x2Prime, y2Prime] = shortenToward(x2, y2, elbowX, elbowY, destReduction);
+ }
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(elbowX)},${Math.round(elbowY)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = elbowX;
+ midpointY = elbowY;
+ } else {
+ const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
+
+ // we reduce the distance, otherwise the arrow goes from the middle of a square to the other (with the arrow tip on top)
+ let reduction;
+
+ // we can not reduce by as much when the arrow is already very small
+ // otherwise, the direction of the arrow flips and points the wrong way
+ if (isSmallMove(move)) {
+ // we remove 40% a square length
+ reduction = bound1.width * 0.40;
+ } else {
+ // we remove 50% a square length
+ reduction = bound1.width * 0.50;
+ }
+
+ const reducedDist = dist - reduction;
+ const ratio = reducedDist / dist;
+
+ const x1Prime = x2 + ratio * (x1 - x2);
+ const y1Prime = y2 + ratio * (y1 - y2);
+ const x2Prime = x1 + ratio * (x2 - x1);
+ const y2Prime = y1 + ratio * (y2 - y1);
+
+ pathD = `M${Math.round(x1Prime)},${Math.round(y1Prime)} L${Math.round(x2Prime)},${Math.round(y2Prime)}`;
+ midpointX = (x1Prime + x2Prime) / 2;
+ midpointY = (y1Prime + y2Prime) / 2;
+ }
+
+ const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ arrowPath.setAttribute('d', pathD);
+ arrowPath.style.strokeWidth = `${strokeWidth}px`;
+ arrowPath.style.fill = 'none';
+ arrowPath.style.strokeLinejoin = 'round';
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ arrowPath.style.stroke = PRIMARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-primary)';
+ break;
+ case EngineArrowType.SECONDARY:
+ arrowPath.style.stroke = SECONDARY_ARROW_COLOR;
+ arrowPath.style.markerEnd = 'url(#head-secondary)';
+ break;
+ }
+
+ // draw number circle
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute('r', (bound1.width * 0.20).toString());
+ circle.setAttribute('cx', midpointX.toString());
+ circle.setAttribute('cy', midpointY.toString());
+ circle.setAttribute('fill', 'white');
+ circle.setAttribute('stroke-width', (bound1.width * 0.04).toString());
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ circle.setAttribute('stroke', PRIMARY_ARROW_COLOR);
+ break;
+ case EngineArrowType.SECONDARY:
+ circle.setAttribute('stroke', SECONDARY_ARROW_COLOR);
+ break;
+ }
+
+ // draw number text
+ const numberLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ numberLabel.setAttribute('x', midpointX.toString());
+ numberLabel.setAttribute('y', midpointY.toString());
+ numberLabel.setAttribute('text-anchor', 'middle');
+ numberLabel.setAttribute('dominant-baseline', 'middle');
+ numberLabel.setAttribute('font-size', (bound1.width * 0.25).toString() + 'px');
+ numberLabel.setAttribute('font-weight', 'bold');
+
+ switch (type) {
+ case EngineArrowType.PRIMARY:
+ numberLabel.setAttribute('fill', PRIMARY_ARROW_COLOR);
+ numberLabel.innerHTML = '1';
+ break;
+ case EngineArrowType.SECONDARY:
+ numberLabel.setAttribute('fill', SECONDARY_ARROW_COLOR);
+ numberLabel.innerHTML = '2';
+ break;
+ }
+
+ return [arrowPath, circle, numberLabel];
+ }
+
+ #handlePieceHolderClickEvent(e, position) {
+ let onlyClickedSquareAndNoImage = true;
+
+ let images = document.getElementsByClassName('piece-image');
+ for (let i = 0; i < images.length; i++) {
+ if (images[i].contains(e.target)) {
+ onlyClickedSquareAndNoImage = false;
+ break;
+ }
+ }
+
+ if (onlyClickedSquareAndNoImage) {
+ this.#clickedOnSquare(position);
+ }
+ }
+
+ #clickedOnPiece(position) {
+ if (this.#isPlayerMoveEnabled) {
+ let board = this.#board;
+ let selected = this.#selectedPiecePosition;
+
+ if (selected == null && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting the piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else if (Position.areEquals(selected, position)) {
+ // if the piece user just clicked on is the same as the one he had selected before,
+ // then user is un-selecting the piece
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containOppositeColors(selected, position)) {
+ // user is capturing the piece he clicked on
+ this.registerMoveIfLegal(new HalfMove(selected, position));
+ this.#hideAllPiecePlaceHolders();
+ } else if (selected != null && board.containSameColors(selected, position) && board.isAllowedToPlayPieceAt(position)) {
+ // user is selecting another piece
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ }
+ }
+ }
+
+ #clickedOnSquare(position) {
+ if (this.#isPlayerMoveEnabled) {
+ if (this.#selectedPiecePosition != null) {
+ this.registerMoveIfLegal(new HalfMove(this.#selectedPiecePosition, position));
+ this.#hideAllPiecePlaceHolders();
+ }
+ }
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param move {HalfMove}
+ */
+ #drawMove(pieceChar, move) {
+ this.#unDrawPiece(move.from);
+ this.#unDrawPiece(move.to);
+ this.#drawPieceAt(pieceChar, move.to);
+ }
+
+ #drawBoard() {
+ /**
+ * @param positionType {string} - one of 'bottom-right', 'bottom-left', 'top-right', 'top-left'
+ * @param drawingPosition {Position}
+ * @param positions {Position[]}
+ * @returns {HTMLDivElement|null}
+ */
+ function drawCrosshairForPositionType(positionType, drawingPosition, positions) {
+ const crosshairToDraw = positions.find(crosshair => Position.areEquals(crosshair, drawingPosition));
+ if (crosshairToDraw != null) {
+ const crosshair = document.createElement('div');
+ crosshair.classList.add('crosshair-square', `crosshair-square-${positionType}`);
+ return crosshair;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param drawingPosition {Position}
+ * @returns {HTMLDivElement[]}
+ */
+ function drawCrosshairs(drawingPosition) {
+ const crosshairTypes = [
+ {type: 'bottom-right', positions: bottomRightCrosshairs},
+ {type: 'bottom-left', positions: bottomLeftCrosshairs},
+ {type: 'top-right', positions: topRightCrosshairs},
+ {type: 'top-left', positions: topLeftCrosshairs}
+ ];
+
+ return crosshairTypes
+ .map(({type, positions}) => drawCrosshairForPositionType(type, drawingPosition, positions))
+ .filter(crosshair => crosshair !== null);
+ }
+
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ // draw rows
+ let row = document.createElement('div');
+ row.className = 'rows';
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let renderPosition;
+ if (this.#flippedRed) {
+ renderPosition = new Position(x, y);
+ } else {
+ renderPosition = new Position(BOARD_WIDTH - x - 1, BOARD_HEIGHT - y - 1);
+ }
+
+ const pieceHolder = document.createElement('div');
+ pieceHolder.id = this.#positionToElementId('square', renderPosition);
+ pieceHolder.className = 'piece-holder';
+ pieceHolder.addEventListener('click', (e) => {
+ this.#handlePieceHolderClickEvent(e, renderPosition)
+ });
+
+ // drag and drop listeners
+ pieceHolder.addEventListener('dragover', (e) => e.preventDefault());
+ pieceHolder.addEventListener('dragenter', (e) => e.preventDefault());
+ pieceHolder.addEventListener('drop', (e) => {
+ this.#handlePieceHolderDropEvent(e, renderPosition);
+ });
+
+ const legalMovePlaceHolder = document.createElement('div');
+ legalMovePlaceHolder.id = this.#positionToElementId('legal_move', renderPosition);
+ pieceHolder.appendChild(legalMovePlaceHolder);
+
+ if (y === 5) {
+ if (x === 0) {
+ const river = buildDivWithClass('large-river');
+
+ if (!this.#options.mini) {
+ const riverOfTheChuContainer = buildDivWithClass('river-of-the-chu');
+ riverOfTheChuContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/river-of-the-chu-brown.png`));
+
+ const borderOfTheHanContainer = buildDivWithClass('border-of-the-han');
+ borderOfTheHanContainer.append(buildImg(`${this.#options.assetsBaseUrl}/images/border-of-the-han-brown.png`));
+
+ river.append(
+ riverOfTheChuContainer,
+ borderOfTheHanContainer
+ );
+ }
+
+ pieceHolder.append(river);
+ }
+ } else {
+ // draw row of visible squares
+ if (x < BOARD_WIDTH - 1 && y > 0) {
+ const visibleSquare = buildDivWithClass('visible-square');
+ pieceHolder.appendChild(visibleSquare);
+
+ if ((x === 3 && y === 2) || (x === 4 && y === 1) || (x === 3 && y === 9) || (x === 4 && y === 8)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalDescendingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalDescending;
+ }
+ }
+ if ((x === 3 && y === 1) || (x === 4 && y === 2) || (x === 3 && y === 8) || (x === 4 && y === 9)) {
+ if (this.#options.mini) {
+ visibleSquare.innerHTML = diagonalRisingMini;
+ } else {
+ visibleSquare.innerHTML = diagonalRising;
+ }
+ }
+
+ if (x === BOARD_WIDTH - 2) {
+ visibleSquare.classList.add('visible-square-last-file');
+ }
+
+ // draw crosshairs
+ drawCrosshairs(new Position(x, y))
+ .forEach(element => visibleSquare.append(element));
+ }
+ }
+ row.appendChild(pieceHolder);
+ }
+ this.#boardContainer.appendChild(row);
+ }
+
+ // add bottom border to last row of visible squares
+ if (this.#flippedRed) {
+ for (let x = 0; x < BOARD_WIDTH - 1; x++) {
+ const position = new Position(x, 1);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ } else {
+ for (let x = BOARD_WIDTH - 1; x > 0; x--) {
+ const position = new Position(x, BOARD_HEIGHT - 2);
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ square.getElementsByClassName('visible-square')[0].classList.add('visible-square-last-row');
+ }
+ }
+
+ this.#drawCoordinates();
+
+ // TODO: hacky
+ if (this.#isSafari) {
+ let rows = document.getElementsByClassName('rows');
+ for (let i = 0; i < rows.length; i++) {
+ rows[i].classList.add('safari-rows');
+ }
+
+ document.getElementById(this.#options.elementId).classList.add('safari-board-container');
+ }
+
+ if (this.#options.svg) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.id = 'board-svg';
+ this.#boardContainer.append(svg);
+ }
+
+ this.#forceSafariLayoutRefresh();
+ }
+
+ // https://stackoverflow.com/questions/9628507/how-can-i-tell-google-translate-to-not-translate-a-section-of-a-website
+ #drawCoordinates() {
+ function buildFileDiv(label, cssClass, visible) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = label;
+ if (visible) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = cssClass;
+ div.appendChild(span);
+ return div;
+ }
+
+ if (this.#options.showCoordinates) {
+ const orientation = this.#options.coordinatesOrientation;
+ const isSettingEnabled = orientation !== null;
+ // when the user has disabled coordinates we still reserve the space (hidden labels),
+ // so we must pick an arbitrary orientation for the (invisible) labels:
+ const isWfxOriented = orientation !== CoordinatesOrientation.ALGEBRAIC;
+
+ const fileNumbersStyle = this.#options.fileNumbersStyle;
+ // For a given side ('red' | 'black') and its screen position ('top' | 'bottom'),
+ // should we render Chinese numerals?
+ const isChineseOnSide = (side, position) => {
+ switch (fileNumbersStyle) {
+ case FileNumbersStyle.ARABIC_BOTH:
+ return false;
+ case FileNumbersStyle.CHINESE_BOTH:
+ return true;
+ case FileNumbersStyle.CHINESE_BLACK_ONLY:
+ return side === 'black';
+ case FileNumbersStyle.CHINESE_LOWER_ONLY:
+ return position === 'bottom';
+ case FileNumbersStyle.CHINESE_TOP_ONLY:
+ return position === 'top';
+ case FileNumbersStyle.CHINESE_RED_ONLY:
+ default:
+ return side === 'red';
+ }
+ };
+
+ // draw top file coordinates
+ if (isWfxOriented) {
+ let topCoordinatesY = BOARD_HEIGHT - 1;
+ if (!this.#flippedRed) {
+ topCoordinatesY = 0;
+ }
+ // top row shows black's side when red is at the bottom, and red's side when flipped
+ const topSide = this.#flippedRed ? 'black' : 'red';
+ const topChinese = isChineseOnSide(topSide, 'top');
+
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label;
+ if (this.#flippedRed) {
+ // top row: file 1 is on the right (black perspective)
+ label = topChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString();
+ } else {
+ // top row: file 1 is on the left (board flipped, red on top)
+ label = topChinese
+ ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1]
+ : (BOARD_WIDTH - x).toString();
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-top', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, topCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+
+ // draw bottom file coordinates
+ let bottomCoordinatesY = 0;
+ if (!this.#flippedRed) {
+ bottomCoordinatesY = BOARD_HEIGHT - 1;
+ }
+ // bottom row shows red's side when red is at the bottom, and black's side when flipped
+ const bottomSide = this.#flippedRed ? 'red' : 'black';
+ const bottomChinese = isChineseOnSide(bottomSide, 'bottom');
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ // actual text
+ let label;
+ if (isWfxOriented) {
+ if (this.#flippedRed) {
+ // bottom row: file 1 is on the left (red perspective)
+ label = bottomChinese
+ ? CHINESE_FILE_DIGITS[BOARD_WIDTH - x - 1]
+ : (BOARD_WIDTH - x).toString();
+ } else {
+ // bottom row: file 1 is on the right (board flipped, black at bottom)
+ label = bottomChinese ? CHINESE_FILE_DIGITS[x] : (x + 1).toString();
+ }
+ } else {
+ label = UCI_LETTER[x];
+ }
+
+ let div = buildFileDiv(label, 'file-coordinates-bottom', isSettingEnabled);
+ let squareId = this.#positionToElementId('square', new Position(x, bottomCoordinatesY));
+ document.getElementById(squareId).appendChild(div);
+ }
+
+ // draw right-side row coordinates
+ if (!isWfxOriented) {
+ let rightCoordinatesX = BOARD_WIDTH - 1;
+ if (!this.#flippedRed) {
+ rightCoordinatesX = 0;
+ }
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ let span = document.createElement('span');
+ span.classList.add('coordinates-labels', 'notranslate');
+ span.innerHTML = (y + 1).toString();
+ if (isSettingEnabled) {
+ span.style.visibility = 'visible';
+ } else {
+ span.style.visibility = 'hidden';
+ }
+
+ let div = document.createElement('div');
+ div.className = 'rows-coordinates-right';
+ div.appendChild(span);
+ let squareId = this.#positionToElementId('square', new Position(rightCoordinatesX, y));
+ document.getElementById(squareId).appendChild(div);
+ }
+ }
+ }
+ }
+
+ #drawPieces() {
+ this.#board
+ .listPiecePositions()
+ .forEach(piecePosition => this.#drawPiece(piecePosition));
+ }
+
+ /**
+ * Resolves the URL of the image for a given piece char, using this board's
+ * configured `assetsBaseUrl` and `pieceStyle` options. Exposed so external
+ * widgets that share this board's visual identity (e.g. the position
+ * editor's piece palette) can use the exact same image set.
+ *
+ * @param pieceChar {string}
+ * @return {string}
+ */
+ getPieceImageSource(pieceChar) {
+ const style = this.#options.pieceStyle.toLowerCase();
+ return `${this.#options.assetsBaseUrl}/images/pieces/${style}/${pieceImageNames.get(pieceChar)}`;
+ }
+
+ /**
+ * @param piecePosition {PieceAtPosition}
+ */
+ #drawPiece(piecePosition) {
+ this.#drawPieceAt(piecePosition.piece.pieceChar, piecePosition.position);
+ }
+
+ /**
+ * @param pieceChar {string}
+ * @param position {Position}
+ */
+ #drawPieceAt(pieceChar, position) {
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ const img = document.createElement('img');
+ img.id = this.#positionToElementId('image', position);
+ img.className = 'piece-image';
+ if (isBlackPiece(pieceChar)) {
+ img.classList.add('piece-image-black');
+ }
+ img.setAttribute('src', this.getPieceImageSource(pieceChar));
+ img.addEventListener('click', () => this.#clickedOnPiece(position));
+ img.addEventListener('dragstart', (e) => this.#dragStart(e, position));
+ img.addEventListener('dragend', () => this.#dragEnd());
+ square.prepend(img);
+ }
+
+ /**
+ * Forces Safari to recalculate layout and positioning of board elements.
+ *
+ * Safari has known issues with layout calculations, particularly with CSS transforms,
+ * absolute positioning, and element positioning after DOM updates. This method applies
+ * various techniques to force Safari to trigger reflows and recalculate element positions.
+ *
+ * The method uses multiple approaches:
+ * - Hardware acceleration triggers via translateZ(0) transforms
+ * - Forced reflows by accessing offsetHeight
+ * - Position property manipulation on squares and piece images
+ *
+ * Called automatically after board state changes like moves, flips, and piece placement
+ * to ensure visual elements are correctly positioned in Safari.
+ *
+ * @private
+ */
+ #forceSafariLayoutRefresh() {
+ if (this.#isSafari) {
+ setTimeout(() => {
+ const container = this.#boardContainer;
+
+ // force container reflow
+ container.style.transform = 'translateZ(0)';
+ container.offsetHeight;
+ container.style.transform = '';
+
+ // force reflow on all squares
+ container
+ .querySelectorAll('.piece-holder')
+ .forEach(square => {
+ square.style.position = 'relative';
+ square.offsetHeight;
+ square.style.position = '';
+ });
+
+ // force reflow on all piece images
+ container
+ .querySelectorAll('.piece-image')
+ .forEach(piece => {
+ piece.style.transform = 'translateZ(0)';
+ piece.offsetHeight;
+ piece.style.transform = '';
+ });
+ }, 0);
+ }
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param position {Position}
+ */
+ #dragStart(e, position) {
+ console.log('drag start');
+
+ if (this.isPlayerMoveEnabled && this.#board.getColorAt(position) === this.#board.getColorToPlay()) {
+ e.dataTransfer.setData('text/plain', position.toUci());
+ this.#showSelectedPositionAndLegalMovesPlaceHolders(position);
+ } else {
+ e.preventDefault();
+ }
+ }
+
+ #dragEnd() {
+ console.log('drag end');
+
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ /**
+ * @param e {DragEvent}
+ * @param to {Position}
+ */
+ #handlePieceHolderDropEvent(e, to) {
+ const uci = e.dataTransfer.getData('text/plain');
+ const from = Position.parseUci(uci);
+ const move = new HalfMove(from, to);
+ this.registerMoveIfLegal(move, false);
+ this.#hideAllPiecePlaceHolders();
+ }
+
+ #unDrawAllPieces() {
+ Position.getAll().forEach(position => this.#unDrawPiece(position));
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #unDrawPiece(position) {
+ const square = document.getElementById(this.#positionToElementId('square', position));
+ // NB: getElementsByClassName returns a live HTMLCollection; convert to a
+ // static array so removing elements doesn't skip siblings.
+ htmlCollectionToArray(square.getElementsByClassName('piece-image'))
+ .forEach(img => square.removeChild(img));
+ }
+
+ #hideAllPiecePlaceHolders() {
+ Position.getAll().forEach(position => {
+ // in case when the board is a mini board overview on an infinite scroll page
+ // the placeholder can be null if the miniboard has been discarded
+ // which would throw a bunch of errors in the console if we don't check for nullability
+ const placeHolder = this.#locateLegalMovePlaceHolderAt(position);
+ if (placeHolder != null) {
+ placeHolder.classList.remove(
+ 'legal-move-place-holder',
+ 'selected-piece',
+ 'possible-capture',
+ 'highlighted-last-move'
+ );
+ }
+ });
+
+ this.#selectedPiecePosition = null;
+ this.#currentShowingLegalMovesFor = null;
+ }
+
+ #showSelectedPositionAndLegalMovesPlaceHolders(position) {
+ // remove previous placeholders
+ this.#hideAllPiecePlaceHolders();
+
+ // show selected piece
+ this.#locateLegalMovePlaceHolderAt(position).classList.add('selected-piece');
+ this.#selectedPiecePosition = position;
+
+ // show legal moves and possible captures
+ this.#showLegalMovesPlaceHolders(position);
+ }
+
+ /**
+ * @param position {Position}
+ */
+ #showLegalMovesPlaceHolders(position) {
+ this
+ .#board
+ .listLegalMovesFrom(position)
+ .map(move => move.to)
+ .forEach(targetPosition => {
+ let placeHolder = this.#locateLegalMovePlaceHolderAt(targetPosition);
+ if (this.#board.containOppositeColors(position, targetPosition)) {
+ placeHolder.classList.add('possible-capture');
+ } else {
+ placeHolder.classList.add('legal-move-place-holder');
+ }
+ });
+
+ this.#currentShowingLegalMovesFor = position;
+ }
+
+ highlightDebugMove(move, color) {
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-debug');
+ from.style.backgroundColor = color;
+ to.classList.add('highlighted-debug');
+ to.style.backgroundColor = color;
+ }
+
+ hideAllDebugHighlight() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-debug');
+ element.style.backgroundColor = null;
+ });
+ }
+
+ highlightDynamicMove(move) {
+ this.#hideAllPiecePlaceHolders();
+ let from = this.#locateLegalMovePlaceHolderAt(move.from);
+ let to = this.#locateLegalMovePlaceHolderAt(move.to);
+ from.classList.add('highlighted-move');
+ to.classList.add('highlighted-move');
+ }
+
+ hideAllHighlightedDynamicMoves() {
+ Position.getAll().forEach(position => {
+ let element = this.#locateLegalMovePlaceHolderAt(position);
+ element.classList.remove('highlighted-move');
+ });
+ }
+
+ clearBoard() {
+ this.#unDrawAllPieces();
+ this.#hideAllPiecePlaceHolders();
+ this.#board.clearBoard();
+ }
+
+ /**
+ * @param color {string|null}
+ */
+ flipToColor(color) {
+ if (color != null && ((this.#flippedRed && color === Color.BLACK) || (!this.#flippedRed && color === Color.RED))) {
+ this.flip();
+ }
+ }
+
+ /**
+ * Returns the color currently shown at the bottom of the board.
+ *
+ * @return {string}
+ */
+ get bottomColor() {
+ return this.#flippedRed ? Color.RED : Color.BLACK;
+ }
+
+ flip() {
+ this.#boardContainer.innerHTML = '';
+ this.#flippedRed = !this.#flippedRed;
+ this.#drawBoard();
+ this.#drawPieces();
+ this.updateHighlightedChecks();
+ this.#resetDraggableCursors();
+
+ const newColor = this.#flippedRed ? Color.RED : Color.BLACK;
+ this.#afterFlipListeners.forEach(listener => listener(newColor));
+ this.#forceSafariLayoutRefresh();
+ }
+
+ /**
+ *
+ * @param moveFormat {string}
+ */
+ updateMoveFormat(moveFormat) {
+ // easy solution: complete redraw (TODO: can more subtle)
+ this.#boardContainer.innerHTML = '';
+ this.#drawBoard();
+ this.#drawPieces();
+ }
+
+ reRenderPieces() {
+ this.#unDrawAllPieces();
+ this.#drawPieces();
+ }
+
+ /**
+ * Update the piece style and re-render the pieces. Needed because the
+ * options object is otherwise frozen and the piece style can be changed at
+ * runtime by the user (via the settings menu).
+ *
+ * @param pieceStyle {string} one of {@link PieceStyleSetting}
+ */
+ updatePieceStyle(pieceStyle) {
+ if (this.#options.pieceStyle === pieceStyle) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, pieceStyle});
+ this.reRenderPieces();
+ }
+
+ /**
+ * @param enabled {boolean}
+ */
+ setColorblindFriendlyBlackPiecesEnabled(enabled) {
+ if (this.#options.colorblindFriendlyBlackPieces === enabled) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, colorblindFriendlyBlackPieces: enabled});
+ this.#renderColorblindFriendlyBlackPiecesSetting(enabled);
+ }
+
+ /**
+ * Change which numeral system is used for file coordinates in WXF mode.
+ *
+ * @param fileNumbersStyle {string} one of {@link FileNumbersStyle}
+ */
+ setFileNumbersStyle(fileNumbersStyle) {
+ if (this.#options.fileNumbersStyle === fileNumbersStyle) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, fileNumbersStyle});
+ this.#redrawCoordinates();
+ }
+
+ /**
+ * Change the orientation (WXF numerals vs algebraic letters) of the board coordinates.
+ *
+ * @param coordinatesOrientation {string|null} one of {@link CoordinatesOrientation} or
+ * `null` to keep the labels hidden
+ */
+ setCoordinatesOrientation(coordinatesOrientation) {
+ if (this.#options.coordinatesOrientation === coordinatesOrientation) {
+ return;
+ }
+ this.#options = Object.freeze({...this.#options, coordinatesOrientation});
+ this.#redrawCoordinates();
+ }
+
+ #redrawCoordinates() {
+ // remove existing file-coordinate (top + bottom) and right-side rank labels
+ // (rank labels only exist in algebraic mode but the selector is harmless if absent)
+ document.querySelectorAll(`#${this.#options.elementId} .file-coordinates-top,
+ #${this.#options.elementId} .file-coordinates-bottom,
+ #${this.#options.elementId} .rows-coordinates-right`)
+ .forEach(el => el.remove());
+ this.#drawCoordinates();
+ }
+
+ /**
+ * @return {boolean}
+ */
+ toggleShowCoordinates() {
+ let areVisible = this.#areCoordinatesVisible();
+ this.#renderShowCoordinatesSetting(!areVisible);
+ return !areVisible;
+ }
+
+ /**
+ * @param show {boolean}
+ */
+ #renderShowCoordinatesSetting(show) {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ label.style.visibility = show ? 'visible' : 'hidden';
+ }
+ }
+
+ /**
+ * @param enabled {boolean}
+ */
+ #renderColorblindFriendlyBlackPiecesSetting(enabled) {
+ this.#boardContainer.classList.toggle('colorblind-friendly-black-pieces', enabled);
+ }
+
+ #areCoordinatesVisible() {
+ let labels = document.getElementsByClassName('coordinates-labels');
+ for (let label of labels) {
+ if (label.style.visibility === 'hidden') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ #hideAllDraggableCursors() {
+ this.#board
+ .listPiecePositions()
+ .forEach(pieceAtPosition => {
+ const imageId = this.#positionToElementId('image', pieceAtPosition.position);
+ const image = document.getElementById(imageId);
+ // image may be momentarily absent while an animation is in
+ // flight (source-square images are renamed to 'animating-xxx'
+ // until the animation commits). In that case there's nothing
+ // to update here; the animation's onDone will re-run cursor
+ // bookkeeping via #resetDraggableCursors().
+ if (image != null) {
+ image.classList.remove('piece-image-can-move');
+ }
+ });
+ }
+
+ #resetDraggableCursors() {
+ const isMated = () => {
+ try {
+ return this.#board.isMated();
+ } catch (e) {
+ // FIXME: happens when re-loading a game where it's your turn to play and enablePlayerMove is called
+ console.warn('error while checking if mated');
+ return false;
+ }
+ }
+
+ this.#hideAllDraggableCursors();
+
+ if (this.isPlayerMoveEnabled && !isMated()) {
+ const colorToPlay = this.#board.getColorToPlay();
+
+ this.#board
+ .listPiecePositions()
+ .filter(piecePosition => colorToPlay === piecePosition.pieceColor)
+ .map(piecePosition => piecePosition.position)
+ .filter(position => this.#board.listLegalMovesFrom(position).length > 0)
+ .forEach(position => {
+ const imageId = this.#positionToElementId('image', position);
+ const image = document.getElementById(imageId);
+ // see #hideAllDraggableCursors: image may be absent
+ // mid-animation (ID temporarily renamed).
+ if (image != null) {
+ image.classList.add('piece-image-can-move');
+ }
+ });
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {HTMLElement}
+ */
+ #locateLegalMovePlaceHolderAt(position) {
+ return document.getElementById(this.#positionToElementId('legal_move', position));
+ }
+
+ /**
+ * @param position {Position}
+ * @param prefix {string}
+ * @returns {string}
+ */
+ #positionToElementId(prefix, position) {
+ return boardPositionToElementId(this.#options.elementId, prefix.toLowerCase(), position);
+ }
+
+}
+
+/**
+ *
+ * @param boardId {string}
+ * @param prefix {string}
+ * @param position {Position}
+ */
+function boardPositionToElementId(boardId, prefix, position) {
+ return `${boardId}-${prefix.toLowerCase()}-${position.x}-${position.y}`;
+}
+
+/**
+ * @param elementId {string}
+ * @returns {Position}
+ */
+function parsePositionFromElementId(elementId) {
+ const split = elementId.split('-');
+ const x = Number(split[split.length - 2]);
+ const y = Number(split[split.length - 1]);
+ return new Position(x, y);
+}
+
+/**
+ * Adds a miniature board that appears on hover for an element.
+ * Uses {@link createWebappBoardGui} so piece images are served from the
+ * correct base URL (local server in dev, CDN in production).
+ *
+ * @param element {HTMLElement} - The element to attach hover listeners to
+ * @param gameId {string} - Unique identifier for this miniboard
+ * @param fen {string} - FEN string representing the board position
+ * @param playerColor {string} - Color to flip the board to
+ * @param lazy {boolean} - If true, only create the board on first mouseenter (default: false)
+ * @returns {HTMLElement|null} - The created miniboard div (null if lazy and not yet created)
+ */
+function addMiniboardDiv(element, gameId, fen, playerColor, lazy = false) {
+ const LEFT_MARGIN = 12;
+ const MINI_BOARD_HEIGHT = 256 / 0.9;
+
+ const miniBoardId = `mini-board-overview-${gameId}`;
+ let miniBoardDiv = null;
+
+ function createBoard() {
+ if (miniBoardDiv) return;
+
+ miniBoardDiv = document.createElement('div');
+ miniBoardDiv.id = miniBoardId;
+ miniBoardDiv.classList.add(
+ 'board-container',
+ 'mini-board-container',
+ 'mini-board-overview'
+ );
+
+ document.body.appendChild(miniBoardDiv);
+
+ const boardGui = createWebappBoardGui({
+ elementId: miniBoardId,
+ showCoordinates: false,
+ mini: true,
+ forceRenderChecks: true,
+ });
+ boardGui.loadFen(fen);
+ boardGui.flipToColor(playerColor);
+ boardGui.updateHighlightedChecks();
+ }
+
+ // Create board immediately if not lazy
+ if (!lazy) {
+ createBoard();
+ }
+
+ // listeners
+ function showMiniboard() {
+ // Create board on first hover if lazy
+ if (!miniBoardDiv) {
+ createBoard();
+ }
+
+ const gameItemRect = element.getBoundingClientRect();
+ const left = gameItemRect.right + LEFT_MARGIN + window.scrollX;
+ const top = gameItemRect.top + window.scrollY + (gameItemRect.height / 2) - (MINI_BOARD_HEIGHT / 2);
+ miniBoardDiv.style.top = `${top}px`;
+ miniBoardDiv.style.left = `${left}px`;
+ miniBoardDiv.style.display = 'block';
+ }
+
+ function hideMiniboard() {
+ if (miniBoardDiv) {
+ miniBoardDiv.style.display = 'none';
+ }
+ }
+
+ element.addEventListener('mouseenter', showMiniboard);
+ element.addEventListener('mouseleave', hideMiniboard);
+
+ return miniBoardDiv;
+}
diff --git a/webapp/src/main/resources/public/dist/0.1.0/board.css b/webapp/src/main/resources/public/dist/0.1.0/board.css
new file mode 100644
index 000000000..3750030e1
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.1.0/board.css
@@ -0,0 +1,418 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+:root {
+ --crosshair-margin: 5%;
+}
+
+#board-svg {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ z-index: 110;
+ pointer-events: none;
+}
+
+.board-container {
+ margin: auto;
+ aspect-ratio: 0.9;
+ background-color: #876e59;
+ padding: 4%;
+ text-align: initial;
+ touch-action: manipulation;
+ position: relative;
+ /* Matches the shared .basic-box / .info-box rule in style.css so board.css
+ renders identically when used standalone (as a library). */
+ box-shadow: rgba(67, 71, 85, 0.27) 0 0 0.25em, rgba(90, 125, 188, 0.05) 0 0.25em 1em;
+ border: 2px solid #323232;
+ border-radius: 3px;
+}
+
+.safari-board-container {
+ display: flex;
+ display: -webkit-box;
+}
+
+.board-outer-container {
+ position: relative;
+ width: 80%;
+}
+
+.board-container-placeholder {
+ background-color: #808080;
+}
+
+.board-container-placeholder .visible-square,
+.board-container-placeholder .large-river {
+ background-color: #dbd8d8;
+}
+
+.board-container-placeholder .piece-image {
+ filter: grayscale(90%);
+}
+
+.board-container-placeholder .coordinates-labels {
+ color: #dbd8d8;
+}
+
+a.board-outer-container-link-mask {
+ position: absolute;
+ aspect-ratio: 0.9;
+ z-index: 150;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ width: 100%;
+}
+
+.rows {
+ height: 10%;
+}
+
+/* TODO: could be replaced by '.safari-board .rows' */
+.safari-rows {
+ height: 9%;
+ display: inline-block;
+ vertical-align: top;
+}
+
+.piece-holder {
+ float: left;
+ position: relative;
+ height: 100%;
+ aspect-ratio: 1;
+}
+
+.piece-holder .piece-image {
+ width: 95%;
+ height: 95%;
+ margin: 2.5%;
+ position: absolute;
+ border-radius: 100%;
+ box-shadow: rgba(0, 0, 0, 0.24) 0px 2px 5px;
+ z-index: 100;
+}
+
+.board-container.colorblind-friendly-black-pieces .piece-image-black {
+ filter: invert(1);
+}
+
+.piece-holder .piece-image-can-move {
+ cursor: move;
+}
+
+.piece-holder .piece-image-moving {
+ z-index: 110;
+}
+
+.crosshair-square {
+ position: absolute;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ /*background-color: rgba(0, 0, 227, 0.47);*/
+ width: 19%;
+ height: 19%;
+ z-index: 80;
+ box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.crosshair-square-bottom-right {
+ bottom: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-top-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-bottom-left {
+ bottom: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-top-width: 0;
+ border-right-width: 0;
+}
+
+.crosshair-square-top-right {
+ top: var(--crosshair-margin);
+ right: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-left-width: 0;
+}
+
+.crosshair-square-top-left {
+ top: var(--crosshair-margin);
+ left: var(--crosshair-margin);
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+/* 2px because it's the border size of visible-square */
+.safari-board-container .visible-square .crosshair-square-bottom-right,
+.safari-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 2px);
+}
+
+.safari-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+/* 1px because it's the border size of visible-square */
+.safari-mini-board-container .visible-square .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square .crosshair-square-top-right {
+ right: calc(var(--crosshair-margin) + 1px);
+}
+
+.safari-mini-board-container .visible-square-last-file .crosshair-square-bottom-right,
+.safari-mini-board-container .visible-square-last-file .crosshair-square-top-right {
+ right: var(--crosshair-margin);
+}
+
+.visible-square, .large-river {
+ background-color: #ead1af;
+ height: 100%;
+ margin: 50%;
+ aspect-ratio: 1;
+ border: 2px solid rgba(10, 10, 10, 0.8);
+ border-right-width: 0;
+ border-bottom-width: 0;
+ position: absolute;
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+.large-river {
+ aspect-ratio: 8;
+ height: 100%;
+}
+
+.visible-square-last-file,
+.large-river {
+ border-right-width: 2px;
+}
+
+.visible-square-last-row {
+ border-bottom-width: 2px;
+}
+
+.river-of-the-chu,
+.border-of-the-han {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 48%;
+}
+
+.river-of-the-chu {
+ float: left;
+}
+
+.border-of-the-han {
+ float: right;
+}
+
+.river-of-the-chu img,
+.border-of-the-han img {
+ opacity: 60%;
+ height: 70%;
+}
+
+.mini-board-container .piece-image {
+ box-shadow: none;
+}
+
+.mini-board-container .visible-square,
+.mini-board-container .large-river {
+ border-width: 1px;
+ border-bottom-width: 0;
+ border-right-width: 0;
+}
+
+.mini-board-container .visible-square-last-row {
+ border-bottom-width: 1px;
+}
+
+.mini-board-container .visible-square-last-file,
+.mini-board-container .large-river {
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-right {
+ border-bottom-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-bottom-left {
+ border-bottom-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-right {
+ border-top-width: 1px;
+ border-right-width: 1px;
+}
+
+.mini-board-container .crosshair-square-top-left {
+ border-top-width: 1px;
+ border-left-width: 1px;
+}
+
+.mini-board-overview {
+ display: none;
+ position: absolute;
+ z-index: 700;
+ max-width: 236px;
+ width: 236px;
+ padding: 8px;
+}
+
+.safari-mini-board-container .safari-rows {
+ height: 10%;
+}
+
+.safari-mini-board-container {
+ height: 306px;
+ width: 276px;
+}
+
+.file-coordinates-top,
+.file-coordinates-bottom,
+.rows-coordinates-right {
+ font-size: 12px;
+ color: #ead1af;
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.file-coordinates-bottom {
+ margin-top: 100%;
+ margin-left: 50%;
+}
+
+.file-coordinates-top {
+ margin-top: -25%;
+ margin-left: 40%;
+}
+
+.rows-coordinates-right {
+ margin-left: 65%;
+ width: 100%;
+ height: 100%;
+}
+
+.legal-move-place-holder,
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-move,
+.highlighted-check,
+.highlighted-checkmate,
+.highlighted-last-move {
+ position: absolute;
+ border-radius: 100%;
+ z-index: 10;
+}
+
+.selected-piece,
+.possible-capture,
+.highlighted-debug,
+.highlighted-check {
+ width: 110%;
+ height: 110%;
+ margin: -5%;
+}
+
+.highlighted-checkmate,
+.highlighted-move {
+ width: 120%;
+ height: 120%;
+ margin: -10%;
+}
+
+.legal-move-place-holder {
+ width: 75%;
+ height: 75%;
+ margin: 12.5%;
+}
+
+.highlighted-last-move {
+ width: 115%;
+ height: 115%;
+ margin: -7.5%;
+}
+
+.legal-move-place-holder {
+ background-color: rgba(0, 64, 255, 0.4);
+}
+
+.selected-piece {
+ background-color: rgba(40, 229, 27, 0.7);
+}
+
+.possible-capture {
+ background-color: blueviolet;
+}
+
+.highlighted-check {
+ background-color: orange;
+}
+
+.highlighted-checkmate {
+ background-color: red;
+}
+
+.highlighted-move {
+ background-color: rgba(247, 247, 17, 0.79);
+}
+
+.highlighted-last-move {
+ border: 5px dashed rgba(13, 98, 7, 0.7);
+ box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+}
+
+/* reactive: hide river/han decorations on narrower viewports */
+@media (max-width: 1400px) {
+ .river-of-the-chu,
+ .border-of-the-han {
+ display: none;
+ }
+}
+
+/* reactive: enlarge board on small viewports */
+@media (max-width: 1000px) {
+ .board-outer-container-link-mask,
+ .board-outer-container {
+ width: 90%;
+ }
+
+ /* x2 compared to normal */
+ .safari-mini-board-container {
+ height: 612px;
+ width: 552px;
+ }
+}
diff --git a/webapp/src/main/resources/public/dist/0.1.0/xiangqi.js b/webapp/src/main/resources/public/dist/0.1.0/xiangqi.js
new file mode 100644
index 000000000..4a6d4b797
--- /dev/null
+++ b/webapp/src/main/resources/public/dist/0.1.0/xiangqi.js
@@ -0,0 +1,1470 @@
+/*
+ * Copyright (C) 2026 Encelade SRL
+ * Copyright (C) 2026 elephantchess.io
+ * Copyright (C) 2026 Benoît Vleminckx (benckx)
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see .
+ */
+
+const BOARD_WIDTH = 9;
+const BOARD_HEIGHT = 10;
+const UCI_LETTER = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
+const PIECES_CHARS = ['c', 'r', 'n', 'b', 'a', 'k', 'p', 'm'];
+
+const DEFAULT_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 0';
+
+/**
+ * Manchu chess (Yitong) start FEN.
+ * Red has only: general, 2 advisors, 2 elephants, 5 soldiers, and the super-chariot (M/m) at a1.
+ * The super-chariot combines the powers of the chariot, horse, and cannon.
+ */
+const MANCHU_START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/9/9/M1BAKAB2 w - - 0 0';
+
+// Piece color enum. Lives here (rather than in enums.js) so that xiangqi.js
+// can be used standalone (together with board-gui.js) without pulling in the
+// rest of the webapp's modules.
+const Color = Object.freeze({
+ RED: 'RED',
+ BLACK: 'BLACK'
+});
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isRedPiece(char) {
+ return char.length === 1 && char.toUpperCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {boolean}
+ */
+function isBlackPiece(char) {
+ return char.length === 1 && char.toLowerCase() === char;
+}
+
+/**
+ * @param char {string}
+ * @return {string}
+ */
+function charToPieceColor(char) {
+ if (char.length !== 1) {
+ throw new Error('Not a single char: ' + char);
+ } else {
+ if (isRedPiece(char)) {
+ return Color.RED;
+ } else if (isBlackPiece(char)) {
+ return Color.BLACK;
+ } else {
+ throw new Error('Illegal piece ' + char);
+ }
+ }
+}
+
+function reverseColor(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return Color.BLACK;
+ case Color.BLACK:
+ return Color.RED;
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+function colorToUci(color) {
+ switch (color.toUpperCase()) {
+ case Color.RED:
+ return 'w';
+ case Color.BLACK:
+ return 'b';
+ default:
+ throw Error('Illegal argument color ' + color);
+ }
+}
+
+/**
+ * Reset full count to 0
+ */
+function resetFenFullMovesCount(fen) {
+ let split = fen.split(' ');
+ return split.slice(0, split.length - 1).join(' ') + ' 0';
+}
+
+function validateStartFen(fen) {
+ let board = new Board();
+ board.loadFen(fen);
+
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ throw new Error('Start position is checkmate');
+ } else if (board.isStalemate(Color.RED) || board.isStalemate(Color.BLACK)) {
+ throw new Error('Start position is stalemate');
+ }
+}
+
+/**
+ * Add full moves count and convert to single string
+ * @param {string[]} movesAsPgn
+ */
+function toSingleLinePgn(movesAsPgn) {
+ let pgn = '';
+ for (let i = 0; i < movesAsPgn.length; i++) {
+ let move = movesAsPgn[i];
+ if (i % 2 === 0) {
+ pgn += (Math.floor(i / 2) + 1) + '. ';
+ }
+ pgn += move + ' ';
+ }
+ return pgn;
+}
+
+/**
+ * https://en.wikipedia.org/wiki/Xiangqi#System_3
+ *
+ * @param {HalfMove[]} moves
+ * @param {boolean} renderCheckIndicators
+ * @param {string} startFen
+ * @return {string[]}
+ */
+function translateMovesToPgn(moves, renderCheckIndicators = true, startFen = DEFAULT_START_FEN) {
+ let movesAsPgn = [];
+
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let pieceFrom = board.getPieceAt(move.from);
+ let pieceTarget = board.getPieceAt(move.to);
+ let newPosition = move.to.toAlgebraic();
+
+ let capture = '';
+ if (pieceTarget != null) {
+ capture = 'x';
+ }
+ let pieceLetter = pieceFrom.pieceChar.toUpperCase();
+ let formerPosition = UCI_LETTER[move.from.x];
+ let sameFile = move.from.x === move.to.x;
+ if (pieceLetter === 'K' || sameFile) {
+ formerPosition = '';
+ }
+
+ board.registerMove(move);
+
+ let checkIndicator = '';
+ if (renderCheckIndicators) {
+ if (board.isCheckmate(Color.RED) || board.isCheckmate(Color.BLACK)) {
+ checkIndicator += '#';
+ } else if (board.isInCheck(Color.RED) || board.isInCheck(Color.BLACK)) {
+ checkIndicator += '+';
+ }
+ }
+
+ let moveString = `${pieceLetter}${formerPosition}${capture}${newPosition}${checkIndicator}`;
+ movesAsPgn.push(moveString);
+ });
+
+ return movesAsPgn;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToPrefixedAlgebraic(moves, startFen = DEFAULT_START_FEN) {
+ const formattedMoves = [];
+ const board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ const pieceChar = board.getPieceAt(move.from).pieceChar.toUpperCase();
+ const moveString = `${pieceChar} ${move.toAlgebraic()}`;
+ board.registerMove(move);
+ formattedMoves.push(moveString);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param horizontalSeparator {string} can be '=' or '.'
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesToWxf(moves, horizontalSeparator, startFen) {
+
+ /**
+ * File number in WXF format starts at 1 from the right hand of the player
+ *
+ * @return {number}
+ */
+ function fileAsWxf(color, file) {
+ switch (color) {
+ case Color.RED:
+ return BOARD_WIDTH - file;
+ case Color.BLACK:
+ return file + 1;
+ default:
+ throw new Error('Illegal color ' + color);
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -)
+ */
+ function verticalMoveDirectionChar(color, move) {
+ if ((color === Color.BLACK && move.from.y > move.to.y) || (color === Color.RED && move.from.y < move.to.y)) {
+ return '+';
+ } else {
+ return '-';
+ }
+ }
+
+ /**
+ * @param color {string}
+ * @param move {HalfMove}
+ * @return {string} e.g. Direction indicator sign (+ or -) followed by distance
+ */
+ function verticalMove(color, move) {
+ let direction = verticalMoveDirectionChar(color, move);
+ let distance = Math.abs(move.from.y - move.to.y);
+ return `${direction}${distance}`;
+ }
+
+ /**
+ * File number or sign (+ or -)
+ *
+ * @param board {Board}
+ * @param move {HalfMove}
+ * @return {string}
+ */
+ function fileNumberOrSign(board, move) {
+ let physicalPiece = board.getPieceAt(move.from);
+ let color = physicalPiece.color;
+
+ let allSimilarPiecesOnFile = board.listPiecePositions().filter(pieceAtPosition => {
+ return pieceAtPosition.piece.pieceChar === physicalPiece.pieceChar && pieceAtPosition.position.x === move.from.x
+ });
+
+ if (allSimilarPiecesOnFile.length === 2) {
+ let currentFileY;
+ let otherFileY;
+ if (move.from.y === allSimilarPiecesOnFile[0].position.y) {
+ currentFileY = allSimilarPiecesOnFile[0].position.y;
+ otherFileY = allSimilarPiecesOnFile[1].position.y;
+ } else {
+ currentFileY = allSimilarPiecesOnFile[1].position.y;
+ otherFileY = allSimilarPiecesOnFile[0].position.y;
+ }
+
+ let isOnTop = (currentFileY > otherFileY && color === Color.RED)
+ || (currentFileY < otherFileY && color === Color.BLACK);
+
+ return isOnTop ? '+' : '-';
+ }
+
+ return fileAsWxf(color, move.from.x).toString();
+ }
+
+ let formattedMoves = [];
+ let board = new Board();
+ board.loadFen(startFen);
+
+ moves.forEach(move => {
+ let physicalPiece = board.getPieceAt(move.from);
+ if (physicalPiece == null) {
+ board.printBoard();
+ throw new Error(`no piece at ${move.from.toAlgebraic()}`);
+ }
+ let color = physicalPiece.color;
+ let pieceType = physicalPiece.pieceChar.toUpperCase();
+
+ let moveStr = '';
+ switch (pieceType) {
+ case 'P':
+ case 'C':
+ case 'R':
+ case 'K':
+ if (move.isHorizontal()) {
+ let fileStr = fileNumberOrSign(board, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${fileStr}${horizontalSeparator}${newFile}`;
+ } else {
+ moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`;
+ }
+ break;
+ case 'B':
+ case 'A':
+ case 'N':
+ let currentFileStr = fileNumberOrSign(board, move);
+ let direction = verticalMoveDirectionChar(color, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${currentFileStr}${direction}${newFile}`;
+ break;
+ case 'M':
+ if (move.isHorizontal()) {
+ let fileStr = fileNumberOrSign(board, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${fileStr}${horizontalSeparator}${newFile}`;
+ } else if (move.isVertical()) {
+ moveStr = `${fileNumberOrSign(board, move)}${verticalMove(color, move)}`;
+ } else {
+ let currentFileStr = fileNumberOrSign(board, move);
+ let direction = verticalMoveDirectionChar(color, move);
+ let newFile = fileAsWxf(color, move.to.x);
+ moveStr = `${currentFileStr}${direction}${newFile}`;
+ }
+ break;
+ }
+
+ let pieceCharacter;
+
+ switch (pieceType) {
+ case 'N':
+ pieceCharacter = 'H';
+ break;
+ case 'B':
+ pieceCharacter = 'E';
+ break;
+ default:
+ pieceCharacter = pieceType;
+ break;
+ }
+
+ formattedMoves.push(`${pieceCharacter}${moveStr}`);
+ board.registerMove(move);
+ });
+
+ return formattedMoves;
+}
+
+/**
+ *
+ * Generic version of the functions above
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function translateMovesFormat(moves, moveFormat, startFen) {
+ switch (moveFormat) {
+ case MoveFormatSetting.WXF_DOT:
+ return translateMovesToWxf(moves, '.', startFen);
+ case MoveFormatSetting.WXF_EQUALS:
+ return translateMovesToWxf(moves, '=', startFen);
+ case MoveFormatSetting.PGN:
+ return translateMovesToPgn(moves, false, startFen);
+ case MoveFormatSetting.ALGEBRAIC_EN:
+ return translateMovesToPrefixedAlgebraic(moves, startFen);
+ default:
+ throw new Error('Unsupported move format ' + moveFormat);
+ }
+}
+
+/**
+ *
+ * Mainly for debugging purposes
+ *
+ * @param allMoves {HalfMove[]}
+ * @param format {string}
+ * @param startFen {string}
+ * @return {string[]}
+ */
+function safeTranslateMovesFormat(allMoves, format, startFen) {
+ try {
+ return translateMovesFormat(allMoves, format, startFen);
+ } catch (e) {
+ console.error(e);
+ console.info(`startFen ${startFen}`);
+ console.info(`moves [${allMoves.length}] ${allMoves.map(move => move.toAlgebraic())}`);
+ return allMoves.map(move => move.toAlgebraic());
+ }
+}
+
+/**
+ *
+ * @param moves {HalfMove[]}
+ * @param moveFormat {string}
+ * @param startFen {string}
+ * @return {null|string}
+ */
+function translateMovesFormatTakeLast(moves, moveFormat, startFen) {
+ let translated = translateMovesFormat(moves, moveFormat, startFen);
+ if (translated.length > 0) {
+ return translated[translated.length - 1];
+ } else {
+ return null;
+ }
+}
+
+/**
+ * @param {HalfMove[]} moves
+ * @return {string}
+ */
+function exportMovesToPgnLine(moves) {
+ return toSingleLinePgn(translateMovesToPgn(moves));
+}
+
+/**
+ * @param moves {HalfMove[]}
+ * @param startFen {string}
+ * @return {string}
+ */
+function calculateFen(moves, startFen = DEFAULT_START_FEN) {
+ const board = new Board();
+ board.loadFen(startFen);
+ moves.forEach(move => board.registerMove(move));
+ return board.outputFen();
+}
+
+class Position {
+
+ static redGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), 0);
+ static blackGeneralStartingPosition = new Position(Math.floor(BOARD_WIDTH / 2), BOARD_HEIGHT - 1);
+
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ get file() {
+ return UCI_LETTER[this.x];
+ }
+
+ /**
+ * @return {number}
+ */
+ get rank() {
+ return this.y;
+ }
+
+ existsOnBoard() {
+ return this.x >= 0 &&
+ this.y >= 0 &&
+ this.x < BOARD_WIDTH &&
+ this.y < BOARD_HEIGHT;
+ }
+
+ isInRedPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y <= 2;
+ }
+
+ isInBlackPalace() {
+ return this.x >= 3 &&
+ this.x <= 5 &&
+ this.y >= BOARD_HEIGHT - 3;
+ }
+
+ getTop() {
+ return new Position(this.x, this.y + 1);
+ }
+
+ getBottom() {
+ return new Position(this.x, this.y - 1);
+ }
+
+ getLeft() {
+ return new Position(this.x - 1, this.y);
+ }
+
+ getRight() {
+ return new Position(this.x + 1, this.y);
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllTop() {
+ let squares = [];
+ for (let y = this.y + 1; y < BOARD_HEIGHT; y++) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllBottom() {
+ let squares = [];
+ for (let y = this.y - 1; y >= 0; y--) {
+ squares.push(new Position(this.x, y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllLeft() {
+ let squares = [];
+ for (let x = this.x - 1; x >= 0; x--) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ getAllRight() {
+ let squares = [];
+ for (let x = this.x + 1; x < BOARD_WIDTH; x++) {
+ squares.push(new Position(x, this.y));
+ }
+ return squares;
+ }
+
+ getTopLeft() {
+ return new Position(this.x - 1, this.y + 1);
+ }
+
+ getTopRight() {
+ return new Position(this.x + 1, this.y + 1);
+ }
+
+ getBottomLeft() {
+ return new Position(this.x - 1, this.y - 1);
+ }
+
+ getBottomRight() {
+ return new Position(this.x + 1, this.y - 1);
+ }
+
+ isEqualsTo(other) {
+ return this.x === other.x && this.y === other.y;
+ }
+
+ /**
+ * 0-based
+ */
+ toUci() {
+ return this.file + this.y;
+ }
+
+ /**
+ * 1-based
+ */
+ toAlgebraic() {
+ return this.file + (this.y + 1);
+ }
+
+ toString() {
+ return this.toUci();
+ }
+
+ /**
+ * @return {Position[]}
+ */
+ static getAll() {
+ let positions = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ positions.push(new Position(x, y));
+ }
+ }
+ return positions;
+ }
+
+ static areEquals(p1, p2) {
+ return p1 != null && p2 != null && p1.isEqualsTo(p2);
+ }
+
+ /**
+ * @param uci {string}
+ * @return {Position}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 2) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ let x = UCI_LETTER.indexOf(uci[0])
+ if (x < 0) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ if (x >= BOARD_WIDTH) {
+ throw new Error('Incorrect position UCI: ' + uci);
+ }
+ return new Position(x, Number(uci[1]));
+ }
+
+}
+
+class PhysicalPiece {
+
+ #pieceChar;
+ #initPosition;
+
+ /**
+ * @param pieceChar {string}
+ * @param initPosition {Position}
+ */
+ constructor(pieceChar, initPosition) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid piece ' + pieceChar);
+ }
+
+ this.#pieceChar = pieceChar;
+ this.#initPosition = initPosition;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceChar() {
+ return this.#pieceChar;
+ }
+
+ get color() {
+ return charToPieceColor(this.#pieceChar);
+ }
+
+ /**
+ * @param other {PhysicalPiece}
+ * @return {boolean}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.#pieceChar === other.#pieceChar &&
+ this.#initPosition.isEqualsTo(other.#initPosition);
+ }
+
+ toString() {
+ return `${this.#pieceChar}{${this.#initPosition.toAlgebraic()}}`;
+ }
+
+}
+
+class PieceAtPosition {
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ constructor(piece, position) {
+ this.piece = piece;
+ this.position = position;
+ }
+
+ /**
+ * @return {string}
+ */
+ get pieceColor() {
+ return this.piece.color;
+ }
+
+ isColor(color) {
+ return this.pieceColor === color;
+ }
+
+ /**
+ * @param other {PieceAtPosition}
+ */
+ isEqualsTo(other) {
+ return other != null &&
+ this.piece.pieceChar === other.piece.pieceChar &&
+ this.position.isEqualsTo(other.position);
+ }
+
+ toString() {
+ return this.piece.pieceChar + ' at ' + this.position.toAlgebraic();
+ }
+
+}
+
+class HalfMove {
+
+ /**
+ * @type {Position}
+ */
+ from;
+
+ /**
+ * @type {Position}
+ */
+ to;
+
+ constructor(from, to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ toUci() {
+ return this.from.toUci() + this.to.toUci();
+ }
+
+ toAlgebraic() {
+ return this.from.toAlgebraic() + this.to.toAlgebraic();
+ }
+
+ isHorizontal() {
+ return this.from.y === this.to.y;
+ }
+
+ isVertical() {
+ return this.from.x === this.to.x;
+ }
+
+ toString() {
+ return this.toAlgebraic();
+ }
+
+ /**
+ * @param uci {string}
+ * @return {HalfMove}
+ */
+ static parseUci(uci) {
+ if (uci.length !== 4) {
+ throw new Error('Incorrect move UCI: ' + uci);
+ }
+ let from = Position.parseUci(uci.substring(0, 2));
+ let to = Position.parseUci(uci.substring(2, 4));
+ return new HalfMove(from, to);
+ }
+
+ /**
+ * @param movesAsUci {string[]}
+ */
+ static parseUciMultipleMoves(movesAsUci) {
+ return movesAsUci.map(moveAsUci => HalfMove.parseUci(moveAsUci));
+ }
+
+ static areEquals(m1, m2) {
+ return m1 != null && m2 != null && Position.areEquals(m1.from, m2.from) && Position.areEquals(m1.to, m2.to);
+ }
+
+ static uciToAlgebraic(uci) {
+ return HalfMove.parseUci(uci).toAlgebraic();
+ }
+
+}
+
+class Board {
+
+ #content = [];
+ #redToPlay = true;
+ #enforceColorTurn = true;
+ #fullMovesCounts = 0;
+
+ constructor() {
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ this.#content.push([null]);
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ this.#content[x][y] = null;
+ }
+ }
+ }
+
+ // TODO: update "full moves count" accordingly
+ loadFen(fen) {
+ const split = fen.split(' ');
+ const positionsFen = split[0];
+ const gameStateFen = split[1];
+ const fenLines = positionsFen.trim().split("/")
+ if (fenLines.length !== BOARD_HEIGHT) {
+ throw new Error('Invalid FEN: wrong number of component');
+ }
+ this.clearBoard();
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ this.#loadFenLine(fenLines[BOARD_HEIGHT - 1 - y], y);
+ }
+
+ switch (gameStateFen.toLowerCase().trim()[0]) {
+ case 'w':
+ case 'r':
+ this.#redToPlay = true;
+ break;
+ case 'b':
+ this.#redToPlay = false;
+ break;
+ default:
+ throw new Error('Invalid FEN: can not determine which side plays next');
+ }
+ }
+
+ /**
+ * @param fenLine {string}
+ * @param y {number}
+ */
+ #loadFenLine(fenLine, y) {
+ let x = 0
+ for (let c = 0; fenLine.length - 1 && x < BOARD_WIDTH; c++) {
+ const char = fenLine.charAt(c);
+ if (char >= '0' && char <= '9') {
+ x += Number(char);
+ } else {
+ this.#content[x][y] = new PhysicalPiece(char, new Position(x, y));
+ x++;
+ }
+ }
+ }
+
+ outputFen() {
+ let ranks = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ ranks.push(this.#rankToFen(y));
+ }
+ return ranks.join('/') + ' ' + colorToUci(this.getColorToPlay()) + ' - - 0 ' + this.#fullMovesCounts.toString();
+ }
+
+ #rankToFen(rank) {
+ let rankPieces = Position
+ .getAll()
+ .filter(position => position.y === rank)
+ .map(position => this.getPieceAt(position));
+
+ let count = 0
+ let result = '';
+ rankPieces.forEach(piece => {
+ if (piece == null) {
+ count += 1;
+ } else {
+ if (count > 0) {
+ result += count.toString();
+ count = 0;
+ }
+ result = result.concat(piece.pieceChar);
+ }
+ });
+ if (count > 0) {
+ result += count.toString();
+ }
+
+ return result;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {PhysicalPiece|null}
+ */
+ getPieceAt(position) {
+ return this.#content[position.x][position.y];
+ }
+
+ /**
+ * @param position {Position}
+ */
+ removePieceFrom(position) {
+ this.#content[position.x][position.y] = null;
+ }
+
+ /**
+ *
+ * @param pieceChar {string}
+ * @param position {Position}
+ * @param enforcePlacementRules {boolean}
+ */
+ addPieceAt(pieceChar, position, enforcePlacementRules) {
+ if (!PIECES_CHARS.includes(pieceChar.toLowerCase())) {
+ throw new Error('Invalid char: ' + pieceChar);
+ }
+
+ // TODO: enforce rules (elephant, king, etc.)
+
+ const physicalPiece = new PhysicalPiece(pieceChar, position);
+ this.#setPieceAt(physicalPiece, position);
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param position {Position}
+ */
+ #setPieceAt(piece, position) {
+ this.#content[position.x][position.y] = piece;
+ }
+
+ /**
+ * @return {PieceAtPosition[]}
+ */
+ listPiecePositions() {
+ return Position
+ .getAll()
+ .filter(position => this.#hasPieceAt(position))
+ .map(position => new PieceAtPosition(this.getPieceAt(position), position));
+ }
+
+ /**
+ * Find all positions where a piece of type {@param piece} is located
+ * (i.e. max 5 for pawns, max 2 for knights, etc.)
+ *
+ * @param pieceChar {string}
+ * @return {Position[]}
+ */
+ listPositionsForPiece(pieceChar) {
+ let result = [];
+ Position.getAll().forEach(position => {
+ let physicalPiece = this.getPieceAt(position);
+ if (physicalPiece != null && physicalPiece.pieceChar === pieceChar) {
+ result.push(position);
+ }
+ });
+ return result;
+ }
+
+ clearBoard() {
+ this.#fullMovesCounts = 0;
+ Position.getAll().forEach(position => this.removePieceFrom(position));
+ }
+
+ /**
+ * @return {boolean} true if the piece at {@param position} matches the color that has to play now,
+ * or if "enforcedColorTurn" is disabled
+ */
+ isAllowedToPlayPieceAt(position) {
+ if (this.#enforceColorTurn) {
+ return this.#hasPieceAt(position) && this.isColorToPlay(this.getPieceAt(position));
+ } else {
+ // FIXME: here we don't check it's not empty? doesn't seem consistent
+ return true;
+ }
+ }
+
+ getColorToPlay() {
+ if (this.#redToPlay) {
+ return Color.RED;
+ } else {
+ return Color.BLACK;
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @return {boolean}
+ */
+ isColorToPlay(piece) {
+ return (piece.color === Color.RED && this.#redToPlay) || (piece.color === Color.BLACK && !this.#redToPlay);
+ }
+
+ /**
+ * @param color {string}
+ */
+ forceColorToPlay(color) {
+ this.#redToPlay = (color === Color.RED);
+ }
+
+ /**
+ * Return captured piece, or null if no piece was taken
+ */
+ registerMove(move) {
+ let piece = this.getPieceAt(move.from);
+ if (piece == null) {
+ throw new Error('No piece to move at ' + move.from);
+ } else if (this.#enforceColorTurn && !this.isColorToPlay(piece)) {
+ throw new Error(`It is ${this.getColorToPlay()}'s turn to play`);
+ } else {
+ if (this.#isPossibleMove(move)) {
+ return this.#registerMove(piece, move)
+ } else {
+ throw new Error(`Move ${move} is not possible for ${piece.pieceChar}`);
+ }
+ }
+ }
+
+ /**
+ * @param piece {PhysicalPiece}
+ * @param move {HalfMove}
+ * @return {PhysicalPiece|null} captured piece, or null if no piece was taken
+ */
+ #registerMove(piece, move) {
+ let targetPiece = this.getPieceAt(move.to);
+ this.removePieceFrom(move.from);
+ this.#setPieceAt(piece, move.to);
+ this.#redToPlay = !this.#redToPlay;
+ if (piece.color === Color.BLACK) {
+ this.#fullMovesCounts++;
+ }
+ return targetPiece;
+ }
+
+ /**
+ * @param color {string}
+ * @return {boolean}
+ */
+ isInCheck(color) {
+ let generalPosition = this.findGeneral(color).position;
+ return this
+ .#listAllMovesForColor(reverseColor(color))
+ .map(move => move.to)
+ .some(position => position.isEqualsTo(generalPosition));
+ }
+
+ isCheckmate(color) {
+ return color === this.getColorToPlay() && this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ isStalemate(color) {
+ return color === this.getColorToPlay() && !this.isInCheck(color) && this.#allMovesAreIllegal(color);
+ }
+
+ #allMovesAreIllegal(color) {
+ return this.#listAllMovesForColor(color).every(move => this.#isMoveIllegal(move, color));
+ }
+
+ isMated() {
+ return this.isCheckmate(Color.RED) ||
+ this.isCheckmate(Color.BLACK) ||
+ this.isStalemate(Color.RED) ||
+ this.isStalemate(Color.BLACK);
+ }
+
+ /**
+ * @param color
+ * @return {PieceAtPosition|null}
+ */
+ findGeneral(color) {
+ return this
+ .listPiecePositions()
+ .find(piecePosition =>
+ piecePosition.piece.pieceChar.toUpperCase() === 'K' && piecePosition.isColor(color)
+ );
+ }
+
+ #isMoveIllegal(move, color) {
+ let boardCopy = this.copy();
+ if (boardCopy.#isPossibleMove(move)) {
+ boardCopy.registerMove(move);
+ return boardCopy.#areGeneralsFacing() || boardCopy.isInCheck(color);
+ } else {
+ return true;
+ }
+ }
+
+ #areGeneralsFacing() {
+ let red = this.findGeneral(Color.RED);
+ let black = this.findGeneral(Color.BLACK);
+ if (red == null || black == null) {
+ return false;
+ } else {
+ let redGeneralPosition = red.position;
+ let blackGeneralPosition = black.position;
+ return red.position.x === black.position.x && this.#noPieceOnFileBetween(redGeneralPosition, blackGeneralPosition);
+ }
+ }
+
+ #noPieceOnFileBetween(p1, p2) {
+ if (p1.x !== p2.x) {
+ return false;
+ } else {
+ for (let y = p1.y + 1; y < p2.y; y++) {
+ let position = new Position(p1.x, y);
+ if (this.#hasPieceAt(position)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ #listAllMovesForColor(color) {
+ return this
+ .listPiecePositions()
+ .filter(piecePosition => piecePosition.isColor(color))
+ .flatMap(piecePosition => this.#listAllMovesFrom(piecePosition.position));
+ }
+
+ #isPossibleMove(move) {
+ return this
+ .#listAllMovesFromAsPositions(move.from)
+ .some(targetPosition => Position.areEquals(move.to, targetPosition));
+ }
+
+ isLegalMove(move) {
+ let color = this.getColorAt(move.from);
+ if (color == null) {
+ return false;
+ } else {
+ return !this.#isMoveIllegal(move, color);
+ }
+ }
+
+ /**
+ * @returns {HalfMove[]}
+ */
+ listLegalMovesFrom(position) {
+ let color = this.getColorAt(position);
+ if (color == null) {
+ return [];
+ } else {
+ return this.#listAllMovesFrom(position).filter(move => !this.#isMoveIllegal(move, color));
+ }
+ }
+
+ /**
+ * Lists every legal move available to the given color (defaults to the
+ * color whose turn it is to play).
+ *
+ * @param color {string} optional, defaults to {@link getColorToPlay}
+ * @returns {HalfMove[]}
+ */
+ listAllLegalMoves(color = this.getColorToPlay()) {
+ const moves = [];
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ for (let y = 0; y < BOARD_HEIGHT; y++) {
+ const pos = new Position(x, y);
+ if (this.getColorAt(pos) === color) {
+ moves.push(...this.listLegalMovesFrom(pos));
+ }
+ }
+ }
+ return moves;
+ }
+
+ /**
+ * @returns Array of {@class HalfMove} one can go to from {@param from}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFrom(from) {
+ return this
+ .#listAllMovesFromAsPositions(from)
+ .map(to => new HalfMove(from, to));
+ }
+
+ /**
+ * @returns {Position[]} one can go to from {@param position}. Includes illegal moves (e.g. that would put player in check)
+ */
+ #listAllMovesFromAsPositions(position) {
+ switch (this.getPieceAt(position).pieceChar.toLowerCase()) {
+ case 'c':
+ return this.#listMovesForCannon(position);
+ case 'r':
+ return this.#listMovesForChariot(position);
+ case 'n':
+ return this.#listMovesForHorse(position);
+ case 'b':
+ return this.#listMovesForElephant(position);
+ case 'a':
+ return this.#listMovesForAdvisor(position);
+ case 'k':
+ return this.#listMovesForGeneral(position);
+ case 'p':
+ return this.#listMovesForSoldier(position);
+ case 'm':
+ return this.#listMovesForSuperChariot(position);
+ default:
+ throw new Error('Not implemented for ' + this.getPieceAt(position).pieceChar)
+ }
+ }
+
+ #listMovesForCannon(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForCannon(position, allTargetPosition) {
+ let result = [];
+ let foundPivot = false;
+
+ for (let i = 0; i < allTargetPosition.length; i++) {
+ let targetPosition = allTargetPosition[i];
+ if (!foundPivot) {
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else {
+ foundPivot = true;
+ }
+ } else {
+ if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForChariot(position) {
+ let result = [];
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllRight()));
+ return result;
+ }
+
+ #filterMovesForChariot(position, allTargetPositions) {
+ let result = [];
+
+ for (let i = 0; i < allTargetPositions.length; i++) {
+ let targetPosition = allTargetPositions[i];
+ if (!this.#hasPieceAt(targetPosition)) {
+ result.push(targetPosition);
+ } else if (this.containOppositeColors(position, targetPosition)) {
+ result.push(targetPosition);
+ return result;
+ } else if (this.containSameColors(position, targetPosition)) {
+ return result;
+ }
+ }
+
+ return result;
+ }
+
+ #listMovesForHorse(position) {
+ let result = [];
+ let top = position.getTop();
+ let bottom = position.getBottom();
+ let left = position.getLeft();
+ let right = position.getRight();
+ if (top.existsOnBoard() && !this.#hasPieceAt(top)) {
+ result.push(top.getTopLeft());
+ result.push(top.getTopRight());
+ }
+ if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) {
+ result.push(bottom.getBottomLeft());
+ result.push(bottom.getBottomRight());
+ }
+ if (left.existsOnBoard() && !this.#hasPieceAt(left)) {
+ result.push(left.getTopLeft());
+ result.push(left.getBottomLeft());
+ }
+ if (right.existsOnBoard() && !this.#hasPieceAt(right)) {
+ result.push(right.getTopRight());
+ result.push(right.getBottomRight());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(targetPosition, position)
+ );
+ }
+
+ #listMovesForElephant(position) {
+ let result = [];
+ let topLeft = position.getTopLeft();
+ let topRight = position.getTopRight();
+ let bottomRight = position.getBottomRight();
+ let bottomLeft = position.getBottomLeft();
+
+ if (topLeft.existsOnBoard() && !this.#hasPieceAt(topLeft)) {
+ result.push(topLeft.getTopLeft());
+ }
+ if (topRight.existsOnBoard() && !this.#hasPieceAt(topRight)) {
+ result.push(topRight.getTopRight());
+ }
+ if (bottomRight.existsOnBoard() && !this.#hasPieceAt(bottomRight)) {
+ result.push(bottomRight.getBottomRight());
+ }
+ if (bottomLeft.existsOnBoard() && !this.#hasPieceAt(bottomLeft)) {
+ result.push(bottomLeft.getBottomLeft());
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() &&
+ !this.containSameColors(position, targetPosition) &&
+ !this.#areOnOppositeSidesOfTheRiver(position, targetPosition)
+ );
+ }
+
+ #listMovesForAdvisor(position) {
+ let result = [];
+ result.push(position.getTopLeft());
+ result.push(position.getTopRight());
+ result.push(position.getBottomRight());
+ result.push(position.getBottomLeft());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #listMovesForGeneral(position) {
+ let result = [];
+ result.push(position.getTop());
+ result.push(position.getBottom());
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ return this.#filterMovesInItsPalace(position, result);
+ }
+
+ #filterMovesInItsPalace(position, allTargetPosition) {
+ if (this.#hasRedPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInRedPalace() && !this.#hasRedPieceAt(target)
+ );
+ } else if (this.#hasBlackPieceAt(position)) {
+ return allTargetPosition.filter(target =>
+ target.existsOnBoard() && target.isInBlackPalace() && !this.#hasBlackPieceAt(target)
+ );
+ } else {
+ console.error("we should never pass here")
+ return [];
+ }
+ }
+
+ #listMovesForSoldier(position) {
+ let result = [];
+ if (this.#hasRedPieceAt(position)) {
+ // is red
+ result.push(position.getTop());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.redGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else if (this.#hasBlackPieceAt(position)) {
+ // is black
+ result.push(position.getBottom());
+ if (this.#areOnOppositeSidesOfTheRiver(position, Position.blackGeneralStartingPosition)) {
+ result.push(position.getLeft());
+ result.push(position.getRight());
+ }
+ } else {
+ console.warn("we should never pass here")
+ return [];
+ }
+
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition)
+ );
+ }
+
+ /**
+ * Super-chariot (Manchu Banner): combines the powers of the chariot, horse, and cannon.
+ */
+ #listMovesForSuperChariot(position) {
+ let result = [];
+ // Chariot moves
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForChariot(position, position.getAllRight()));
+ // Cannon moves
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllTop()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllBottom()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllLeft()));
+ result = result.concat(this.#filterMovesForCannon(position, position.getAllRight()));
+ // Horse moves
+ const top = position.getTop();
+ const bottom = position.getBottom();
+ const left = position.getLeft();
+ const right = position.getRight();
+ if (top.existsOnBoard() && !this.#hasPieceAt(top)) {
+ result.push(top.getTopLeft());
+ result.push(top.getTopRight());
+ }
+ if (bottom.existsOnBoard() && !this.#hasPieceAt(bottom)) {
+ result.push(bottom.getBottomLeft());
+ result.push(bottom.getBottomRight());
+ }
+ if (left.existsOnBoard() && !this.#hasPieceAt(left)) {
+ result.push(left.getTopLeft());
+ result.push(left.getBottomLeft());
+ }
+ if (right.existsOnBoard() && !this.#hasPieceAt(right)) {
+ result.push(right.getTopRight());
+ result.push(right.getBottomRight());
+ }
+ // Filter: must be on board and not capture same-color pieces
+ return result.filter(targetPosition =>
+ targetPosition.existsOnBoard() && !this.containSameColors(position, targetPosition)
+ );
+ }
+
+ /**
+ * @param position {Position}
+ * @returns {null|string}
+ */
+ getColorAt(position) {
+ let piece = this.getPieceAt(position);
+ if (piece != null) {
+ return piece.color;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasPieceAt(position) {
+ return this.#content[position.x][position.y] != null;
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasRedPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.RED, position);
+ }
+
+ /**
+ * @param position {Position}
+ * @return {boolean}
+ */
+ #hasBlackPieceAt(position) {
+ return this.#hasPieceOfColorAt(Color.BLACK, position);
+ }
+
+ #hasPieceOfColorAt(color, position) {
+ let piece = this.getPieceAt(position);
+ return piece != null && piece.color === color;
+ }
+
+ #areOnOppositeSidesOfTheRiver(p1, p2) {
+ return (p1.y <= 4 && p2.y >= 5) || (p2.y <= 4 && p1.y >= 5);
+ }
+
+ containSameColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasRedPieceAt(p2))
+ || (this.#hasBlackPieceAt(p1) && this.#hasBlackPieceAt(p2));
+ }
+
+ containOppositeColors(p1, p2) {
+ return (this.#hasRedPieceAt(p1) && this.#hasBlackPieceAt(p2))
+ || (this.#hasRedPieceAt(p2) && this.#hasBlackPieceAt(p1));
+ }
+
+ printBoardToLines() {
+ let lines = [];
+ for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
+ let row = '';
+ for (let x = 0; x < BOARD_WIDTH; x++) {
+ let piece = this.getPieceAt(new Position(x, y));
+ if (piece == null) {
+ row += ' ';
+ } else {
+ row += piece.pieceChar + ' ';
+ }
+ }
+ lines.push(row);
+ }
+ return lines;
+ }
+
+ printBoard() {
+ let oneLine = '';
+ this.printBoardToLines().forEach(line => oneLine += line + '\n');
+ console.log(oneLine);
+ }
+
+ /**
+ * @return {Board}
+ */
+ copy() {
+ let copy = new Board();
+ copy.loadFen(this.outputFen());
+ return copy
+ }
+
+}
diff --git a/webapp/src/main/resources/public/images/icons/data-search.png b/webapp/src/main/resources/public/images/icons/data-search.png
new file mode 100644
index 0000000000000000000000000000000000000000..15397ea04f94b2ec0c4ef3b4624c965d58601075
GIT binary patch
literal 22139
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
literal 0
HcmV?d00001
diff --git a/webapp/src/main/resources/public/images/icons/dice.png b/webapp/src/main/resources/public/images/icons/dice.png
new file mode 100644
index 0000000000000000000000000000000000000000..d8189c79d370e1e8bc2b1c35106f46c87ff84b48
GIT binary patch
literal 16641
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
JuR1*be*kX4+oAvf
literal 0
HcmV?d00001
diff --git a/webapp/src/main/resources/public/images/icons/link.png b/webapp/src/main/resources/public/images/icons/link.png
new file mode 100644
index 0000000000000000000000000000000000000000..83e42268c7b129e9f896f9961b01dbeac1fc6ece
GIT binary patch
literal 11789
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