cm-chessboard 8.12.19 → 8.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>cm-chessboard</title>
6
+ <meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0"/>
7
+ <link rel="stylesheet" href="../styles/examples.css"/>
8
+ <link rel="stylesheet" href="../../assets/chessboard.css"/>
9
+ </head>
10
+ <body>
11
+ <h1><a href="../../">cm-chessboard</a></h1>
12
+ <h2>Example: PieceRotation Extension</h2>
13
+ <p>Rotates the piece glyphs in place, by any angle, optionally animated — either all pieces or only those
14
+ of one color. Useful for over-the-board play on a tablet lying flat between two players. Moving pieces
15
+ stays enabled, the rotation survives moves and position changes.</p>
16
+ <div class="board" id="board"></div>
17
+ <div style="clear: both"></div>
18
+ <p>
19
+ <select id="color">
20
+ <option value="">all pieces</option>
21
+ <option value="w">white only</option>
22
+ <option value="b">black only</option>
23
+ </select>
24
+ <button onclick="window.rotate(0)">0&deg;</button>
25
+ <button onclick="window.rotate(90)">90&deg;</button>
26
+ <button onclick="window.rotate(180)">180&deg;</button>
27
+ <button onclick="window.rotate(270)">270&deg;</button>
28
+ <label><input type="checkbox" id="animated" checked> animated</label>
29
+ </p>
30
+ <script type="module">
31
+ import {Chessboard} from "../../src/Chessboard.js"
32
+ import {FEN} from "../../src/model/Position.js"
33
+ import {PieceRotation} from "../../src/extensions/piece-rotation/PieceRotation.js"
34
+
35
+ const board = new Chessboard(document.getElementById("board"), {
36
+ position: FEN.start,
37
+ assetsCache: false,
38
+ assetsUrl: "../../assets/",
39
+ style: {aspectRatio: 0.98},
40
+ extensions: [{class: PieceRotation}]
41
+ })
42
+ board.enableMoveInput(() => {
43
+ return true // allow all moves in this demo
44
+ })
45
+ window.rotate = (angle) => {
46
+ board.setPiecesRotation(angle, {
47
+ color: document.getElementById("color").value || undefined,
48
+ animated: document.getElementById("animated").checked
49
+ })
50
+ }
51
+ </script>
52
+ </body>
53
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-chessboard",
3
- "version": "8.12.19",
3
+ "version": "8.13.0",
4
4
  "description": "A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.",
5
5
  "keywords": [
6
6
  "chess",
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ */
6
+ import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
7
+ import {COLOR} from "../../Chessboard.js"
8
+
9
+ /**
10
+ * Rotates the piece glyphs in place, by any angle, optionally animated —
11
+ * either all pieces or only those of one color. The squares and the position
12
+ * stay untouched, only the glyphs turn around their own center. Useful for
13
+ * over-the-board play on a tablet lying flat between two players: rotate the
14
+ * pieces by 180 degrees towards the player to move.
15
+ *
16
+ * The rotation is implemented as an additional SVGTransform appended to each
17
+ * piece's `use` element, after the translate and scale the view sets there.
18
+ * Appended last, it rotates in sprite coordinates around the tile center, so
19
+ * it composes with any piece position and survives the move animations, which
20
+ * only touch the translate of the surrounding group. A MutationObserver
21
+ * re-applies the angles to freshly drawn pieces, so the rotation survives
22
+ * redraws and position changes from any code path.
23
+ *
24
+ * Registers two methods on the chessboard:
25
+ *
26
+ * // rotate all pieces, animated
27
+ * chessboard.setPiecesRotation(180)
28
+ * // rotate only the black pieces, not animated
29
+ * chessboard.setPiecesRotation(180, {color: COLOR.black, animated: false})
30
+ * // returns a Promise, resolved when the rotation is shown
31
+ * await chessboard.setPiecesRotation(0)
32
+ *
33
+ * chessboard.getPiecesRotation(COLOR.white) // current target angle
34
+ */
35
+ export class PieceRotation extends Extension {
36
+
37
+ constructor(chessboard, props = {}) {
38
+ super(chessboard)
39
+ this.props = {
40
+ angle: 0, // the initial rotation angle in degrees, for all pieces
41
+ animationDuration: 300 // duration of an animated rotation in milliseconds
42
+ }
43
+ Object.assign(this.props, props)
44
+ this.angles = {w: this.props.angle, b: this.props.angle}
45
+ this.targetAngles = {w: this.props.angle, b: this.props.angle}
46
+ this.animations = {w: null, b: null}
47
+
48
+ // Redraws and animations replace the piece elements with fresh ones
49
+ // without the rotation, and they do so from several code paths — some
50
+ // of them after all extension points have fired (redrawPieces runs in
51
+ // the animation queue's callback). A MutationObserver on the pieces
52
+ // group catches every new element, no matter which path created it.
53
+ this.observer = new MutationObserver(() => {
54
+ this.applyAngles()
55
+ })
56
+ this.observer.observe(chessboard.view.piecesGroup, {childList: true, subtree: true})
57
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
58
+ this.observer.disconnect()
59
+ this.cancelAnimation(COLOR.white)
60
+ this.cancelAnimation(COLOR.black)
61
+ })
62
+
63
+ chessboard.setPiecesRotation = this.setPiecesRotation.bind(this)
64
+ chessboard.getPiecesRotation = this.getPiecesRotation.bind(this)
65
+ if (this.props.angle !== 0) {
66
+ this.applyAngles()
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Rotate pieces to `angle` degrees. Returns a Promise which resolves when
72
+ * the rotation is shown, after the animation if animated.
73
+ *
74
+ * @param angle target angle in degrees
75
+ * @param props {color: COLOR.white|COLOR.black|undefined, animated: true}
76
+ * without `color` all pieces are rotated
77
+ */
78
+ setPiecesRotation(angle, props = {}) {
79
+ const {color = undefined, animated = true} = props
80
+ const colors = color ? [color] : [COLOR.white, COLOR.black]
81
+ const promises = colors.map((rotatedColor) => this.rotateColor(rotatedColor, angle, animated))
82
+ return Promise.all(promises)
83
+ }
84
+
85
+ /**
86
+ * The current target angle of a color's pieces.
87
+ */
88
+ getPiecesRotation(color = COLOR.white) {
89
+ return this.targetAngles[color]
90
+ }
91
+
92
+ // -- internal ------------------------------------------------------------
93
+
94
+ rotateColor(color, angle, animated) {
95
+ this.targetAngles[color] = angle
96
+ this.cancelAnimation(color)
97
+ if (!animated || this.props.animationDuration <= 0 || angle === this.angles[color]) {
98
+ this.angles[color] = angle
99
+ this.applyAngles()
100
+ return Promise.resolve()
101
+ }
102
+ return new Promise((resolve) => {
103
+ const startAngle = this.angles[color]
104
+ let startTime = null
105
+ const animation = {resolve}
106
+ const step = (time) => {
107
+ if (!this.chessboard.state) { // board was destroyed
108
+ return
109
+ }
110
+ if (startTime === null) {
111
+ startTime = time
112
+ }
113
+ const t = Math.min(1, (time - startTime) / this.props.animationDuration)
114
+ const progress = t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t // easeInOut
115
+ this.angles[color] = startAngle + (angle - startAngle) * progress
116
+ this.applyAngles()
117
+ if (t < 1) {
118
+ animation.frameHandle = requestAnimationFrame(step)
119
+ } else {
120
+ this.animations[color] = null
121
+ resolve()
122
+ }
123
+ }
124
+ animation.frameHandle = requestAnimationFrame(step)
125
+ this.animations[color] = animation
126
+ })
127
+ }
128
+
129
+ /**
130
+ * A superseded animation stops where it is, its promise resolves. The new
131
+ * rotation starts from the currently shown angle.
132
+ */
133
+ cancelAnimation(color) {
134
+ const animation = this.animations[color]
135
+ if (animation) {
136
+ cancelAnimationFrame(animation.frameHandle)
137
+ animation.resolve()
138
+ this.animations[color] = null
139
+ }
140
+ }
141
+
142
+ applyAngles() {
143
+ const center = this.chessboard.props.style.pieces.tileSize / 2
144
+ const groups = this.chessboard.view.piecesGroup.querySelectorAll("g[data-piece]")
145
+ for (const group of groups) {
146
+ const color = group.getAttribute("data-piece").charAt(0)
147
+ const piece = group.querySelector("use.piece")
148
+ if (piece) {
149
+ this.rotateElement(piece, this.angles[color], center)
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The rotation is the transform appended after the ones the view sets, its
156
+ * index in the list is stored on the element. The item is addressed by
157
+ * position, not by a retained object reference: WebKit returns fresh
158
+ * wrapper objects from `getItem`, an identity check would report the
159
+ * transform as lost on every call and stale rotations would pile up.
160
+ */
161
+ rotateElement(element, angle, center) {
162
+ const list = element.transform.baseVal
163
+ let index = parseInt(element.getAttribute("data-rotation-index"), 10)
164
+ if (isNaN(index) || index >= list.numberOfItems) {
165
+ index = list.numberOfItems
166
+ list.appendItem(element.ownerSVGElement.createSVGTransform())
167
+ element.setAttribute("data-rotation-index", index)
168
+ }
169
+ list.getItem(index).setRotate(angle, center, center)
170
+ }
171
+ }
@@ -0,0 +1,39 @@
1
+ # PieceRotation extension
2
+
3
+ Rotates the piece glyphs in place, by any angle, optionally animated — either all pieces or only those of one color. The squares and the position stay untouched, only the glyphs turn around their own center.
4
+
5
+ The main use case is over-the-board play on a tablet lying flat between two players: rotate the pieces by 180 degrees towards the player to move, so both players see their pieces upright.
6
+
7
+ ## Usage
8
+
9
+ ```js
10
+ import {PieceRotation} from "cm-chessboard/src/extensions/piece-rotation/PieceRotation.js"
11
+
12
+ const board = new Chessboard(context, {
13
+ position: FEN.start,
14
+ assetsUrl: "./assets/",
15
+ extensions: [{class: PieceRotation, props: {animationDuration: 300}}]
16
+ })
17
+
18
+ // rotate all pieces, animated, returns a Promise
19
+ await board.setPiecesRotation(180)
20
+
21
+ // rotate only the black pieces, not animated
22
+ board.setPiecesRotation(180, {color: COLOR.black, animated: false})
23
+
24
+ // the current target angle of a color (default COLOR.white)
25
+ board.getPiecesRotation(COLOR.black)
26
+ ```
27
+
28
+ ## Props
29
+
30
+ | Prop | Default | Description |
31
+ |---------------------|---------|--------------------------------------------------------|
32
+ | `angle` | `0` | Initial rotation angle in degrees, for all pieces |
33
+ | `animationDuration` | `300` | Duration of an animated rotation in milliseconds |
34
+
35
+ ## How it works
36
+
37
+ The rotation is an additional `SVGTransform` appended to each piece's `use` element, after the translate and scale the view sets there. Appended last, it rotates in sprite coordinates around the tile center, so it composes with any piece position and survives the move animations, which only touch the translate of the surrounding group. A `MutationObserver` on the pieces group re-applies the angles to freshly drawn pieces, so the rotation survives redraws and position changes from any code path.
38
+
39
+ The rotation is independent of move input. The dragged ghost piece following the pointer is intentionally not rotated.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ */
6
+
7
+ import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
8
+ import {Chessboard, COLOR} from "../src/Chessboard.js"
9
+ import {FEN} from "../src/model/Position.js"
10
+ import {PieceRotation} from "../src/extensions/piece-rotation/PieceRotation.js"
11
+
12
+ function makeBoard(props = {}) {
13
+ return new Chessboard(document.getElementById("TestPieceRotation"), {
14
+ position: FEN.start,
15
+ assetsUrl: "../assets/",
16
+ ...props
17
+ })
18
+ }
19
+
20
+ function rotationOf(useElement) {
21
+ // the appended rotation is the last transform in the list
22
+ const list = useElement.transform.baseVal
23
+ const item = list.getItem(list.numberOfItems - 1)
24
+ return item.type === SVGTransform.SVG_TRANSFORM_ROTATE ? item.angle : null
25
+ }
26
+
27
+ function piecesOf(chessboard, color) {
28
+ return chessboard.view.piecesGroup.querySelectorAll(`g[data-piece^="${color}"] use.piece`)
29
+ }
30
+
31
+ // The MutationObserver in the extension applies the angles to freshly drawn
32
+ // pieces in a microtask, wait one tick before asserting.
33
+ function nextTick() {
34
+ return new Promise((resolve) => setTimeout(resolve))
35
+ }
36
+
37
+ describe("TestPieceRotation", () => {
38
+
39
+ it("should rotate all pieces instantly and back", async () => {
40
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
41
+ assert.equal(chessboard.getPiecesRotation(), 0)
42
+ await chessboard.setPiecesRotation(180, {animated: false})
43
+ assert.equal(chessboard.getPiecesRotation(COLOR.white), 180)
44
+ assert.equal(chessboard.getPiecesRotation(COLOR.black), 180)
45
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
46
+ assert.equal(pieces.length, 32)
47
+ for (const piece of pieces) {
48
+ assert.equal(rotationOf(piece), 180)
49
+ }
50
+ await chessboard.setPiecesRotation(0, {animated: false})
51
+ for (const piece of pieces) {
52
+ assert.equal(rotationOf(piece), 0)
53
+ }
54
+ chessboard.destroy()
55
+ })
56
+
57
+ it("should rotate only one color", async () => {
58
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
59
+ await chessboard.setPiecesRotation(180, {color: COLOR.black, animated: false})
60
+ assert.equal(chessboard.getPiecesRotation(COLOR.black), 180)
61
+ assert.equal(chessboard.getPiecesRotation(COLOR.white), 0)
62
+ for (const piece of piecesOf(chessboard, "b")) {
63
+ assert.equal(rotationOf(piece), 180)
64
+ }
65
+ for (const piece of piecesOf(chessboard, "w")) {
66
+ assert.equal(rotationOf(piece), 0)
67
+ }
68
+ chessboard.destroy()
69
+ })
70
+
71
+ it("should animate the rotation to the target angle", async () => {
72
+ const chessboard = makeBoard({
73
+ extensions: [{class: PieceRotation, props: {animationDuration: 50}}]
74
+ })
75
+ await chessboard.setPiecesRotation(90)
76
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
77
+ assert.equal(pieces.length, 32)
78
+ for (const piece of pieces) {
79
+ assert.equal(rotationOf(piece), 90)
80
+ }
81
+ chessboard.destroy()
82
+ })
83
+
84
+ it("should keep the rotation on pieces after a position change", async () => {
85
+ const chessboard = makeBoard({extensions: [{class: PieceRotation}]})
86
+ await chessboard.setPiecesRotation(180, {animated: false})
87
+ await chessboard.movePiece("e2", "e4", false)
88
+ await nextTick()
89
+ const moved = chessboard.view.piecesGroup.querySelector('g[data-square="e4"] use.piece')
90
+ assert.true(moved !== null)
91
+ assert.equal(rotationOf(moved), 180)
92
+ // all the other pieces keep it too
93
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
94
+ for (const piece of pieces) {
95
+ assert.equal(rotationOf(piece), 180)
96
+ }
97
+ chessboard.destroy()
98
+ })
99
+
100
+ it("should keep the rotation after an animated move", async () => {
101
+ const chessboard = makeBoard({
102
+ style: {animationDuration: 50},
103
+ extensions: [{class: PieceRotation}]
104
+ })
105
+ await chessboard.setPiecesRotation(180, {animated: false})
106
+ await chessboard.movePiece("b1", "c3", true)
107
+ await nextTick()
108
+ const moved = chessboard.view.piecesGroup.querySelector('g[data-square="c3"] use.piece')
109
+ assert.true(moved !== null)
110
+ assert.equal(rotationOf(moved), 180)
111
+ chessboard.destroy()
112
+ })
113
+
114
+ it("should apply an initial angle from the props", async () => {
115
+ const chessboard = makeBoard({
116
+ extensions: [{class: PieceRotation, props: {angle: 180}}]
117
+ })
118
+ await nextTick()
119
+ for (const piece of chessboard.view.piecesGroup.querySelectorAll("use.piece")) {
120
+ assert.equal(rotationOf(piece), 180)
121
+ }
122
+ chessboard.destroy()
123
+ })
124
+
125
+ it("should supersede a running animation", async () => {
126
+ const chessboard = makeBoard({
127
+ extensions: [{class: PieceRotation, props: {animationDuration: 1000}}]
128
+ })
129
+ chessboard.setPiecesRotation(180) // not awaited, will be superseded
130
+ await chessboard.setPiecesRotation(45, {animated: false})
131
+ assert.equal(chessboard.getPiecesRotation(), 45)
132
+ const pieces = chessboard.view.piecesGroup.querySelectorAll("use.piece")
133
+ for (const piece of pieces) {
134
+ assert.equal(rotationOf(piece), 45)
135
+ }
136
+ chessboard.destroy()
137
+ })
138
+ })
package/test/index.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <div class="board" id="TestPosition"></div>
22
22
  <div class="board" id="TestMarkers"></div>
23
23
  <div class="board" id="TestArrows"></div>
24
+ <div class="board" id="TestPieceRotation"></div>
24
25
  <script type="module">
25
26
  import {teevi} from "../node_modules/teevi/src/teevi.js"
26
27
  import "./TestChessboard.js"
@@ -29,6 +30,7 @@
29
30
  import "./TestArrows.js"
30
31
  import "./TestPosition.js"
31
32
  import "./TestVisualMoveInput.js"
33
+ import "./TestPieceRotation.js"
32
34
  teevi.run()
33
35
  </script>
34
36
  </body>