cm-chessboard
Version:
A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.
298 lines (275 loc) • 11.1 kB
JavaScript
/**
* Author and copyright: Stefan Haack (https://shaack.com)
* Repository: https://github.com/shaack/cm-chessboard
* License: MIT, see file 'LICENSE'
*/
import {FEN, Position} from "../model/Position.js"
import {Svg} from "../lib/Svg.js"
import {EXTENSION_POINT} from "../model/Extension.js"
import {Utils} from "../lib/Utils.js"
/*
* Thanks to markosyan for the idea of the PromiseQueue
* https://medium.com/@karenmarkosyan/how-to-manage-promises-into-dynamic-queue-with-vanilla-javascript-9d0d1f8d4df5
*/
export const ANIMATION_EVENT_TYPE = {
start: "start",
frame: "frame",
end: "end"
}
export class PromiseQueue {
constructor() {
this.queue = []
this.workingOnPromise = false
this.stop = false
}
async enqueue(promise) {
return new Promise((resolve, reject) => {
this.queue.push({
promise, resolve, reject,
})
this.dequeue()
})
}
dequeue() {
if (this.workingOnPromise) {
return
}
if (this.stop) {
this.queue = []
this.stop = false
return
}
const entry = this.queue.shift()
if (!entry) {
return
}
try {
this.workingOnPromise = true
entry.promise().then((value) => {
this.workingOnPromise = false
entry.resolve(value)
this.dequeue()
}).catch(err => {
this.workingOnPromise = false
entry.reject(err)
this.dequeue()
})
} catch (err) {
this.workingOnPromise = false
entry.reject(err)
this.dequeue()
}
return true
}
destroy() {
this.stop = true
}
}
const CHANGE_TYPE = {
move: 0,
appear: 1,
disappear: 2
}
export class PositionsAnimation {
constructor(view, fromPosition, toPosition, duration, callback) {
this.view = view
if (fromPosition && toPosition) {
this.animatedElements = this.createAnimation(fromPosition.squares, toPosition.squares)
this.duration = duration
this.callback = callback
this.frameHandle = requestAnimationFrame(this.animationStep.bind(this))
} else {
console.error("fromPosition", fromPosition, "toPosition", toPosition)
}
this.view.positionsAnimationTask = Utils.createTask()
this.view.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.animation, {
type: ANIMATION_EVENT_TYPE.start
})
}
static seekChanges(fromSquares, toSquares) {
const appearedList = [], disappearedList = [], changes = []
for (let i = 0; i < 64; i++) {
const previousSquare = fromSquares[i]
const newSquare = toSquares[i]
if (newSquare !== previousSquare) {
if (newSquare) {
appearedList.push({piece: newSquare, index: i})
}
if (previousSquare) {
disappearedList.push({piece: previousSquare, index: i})
}
}
}
appearedList.forEach((appeared) => {
let shortestDistance = 8
let foundMoved = null
disappearedList.forEach((disappeared) => {
if (appeared.piece === disappeared.piece) {
const moveDistance = PositionsAnimation.squareDistance(appeared.index, disappeared.index)
if (moveDistance < shortestDistance) {
foundMoved = disappeared
shortestDistance = moveDistance
}
}
})
if (foundMoved) {
disappearedList.splice(disappearedList.indexOf(foundMoved), 1) // remove from disappearedList, because it is moved now
changes.push({
type: CHANGE_TYPE.move,
piece: appeared.piece,
atIndex: foundMoved.index,
toIndex: appeared.index
})
} else {
changes.push({type: CHANGE_TYPE.appear, piece: appeared.piece, atIndex: appeared.index})
}
})
disappearedList.forEach((disappeared) => {
changes.push({type: CHANGE_TYPE.disappear, piece: disappeared.piece, atIndex: disappeared.index})
})
return changes
}
createAnimation(fromSquares, toSquares) {
const changes = PositionsAnimation.seekChanges(fromSquares, toSquares)
const animatedElements = []
changes.forEach((change) => {
const animatedItem = {
type: change.type
}
switch (change.type) {
case CHANGE_TYPE.move:
animatedItem.element = this.view.getPieceElement(Position.indexToSquare(change.atIndex))
animatedItem.element.parentNode.appendChild(animatedItem.element) // move element to top layer
animatedItem.atPoint = this.view.indexToPoint(change.atIndex)
animatedItem.toPoint = this.view.indexToPoint(change.toIndex)
break
case CHANGE_TYPE.appear:
animatedItem.element = this.view.drawPieceOnSquare(Position.indexToSquare(change.atIndex), change.piece)
animatedItem.element.style.opacity = 0
break
case CHANGE_TYPE.disappear:
animatedItem.element = this.view.getPieceElement(Position.indexToSquare(change.atIndex))
break
}
animatedElements.push(animatedItem)
})
return animatedElements
}
animationStep(time) {
if(!this.view || !this.view.chessboard.state) { // board was destroyed
return
}
if (!this.startTime) {
this.startTime = time
}
const timeDiff = time - this.startTime
if (timeDiff <= this.duration) {
this.frameHandle = requestAnimationFrame(this.animationStep.bind(this))
} else {
cancelAnimationFrame(this.frameHandle)
this.animatedElements.forEach((animatedItem) => {
if (animatedItem.type === CHANGE_TYPE.disappear) {
Svg.removeElement(animatedItem.element)
}
})
this.view.positionsAnimationTask.resolve()
this.view.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.animation, {
type: ANIMATION_EVENT_TYPE.end
})
this.callback()
return
}
const t = Math.min(1, timeDiff / this.duration)
let progress = t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t // easeInOut
if (isNaN(progress) || progress > 0.99) {
progress = 1
}
this.animatedElements.forEach((animatedItem) => {
if (animatedItem.element) {
switch (animatedItem.type) {
case CHANGE_TYPE.move:
animatedItem.element.transform.baseVal.removeItem(0)
const transform = (this.view.svg.createSVGTransform())
transform.setTranslate(
animatedItem.atPoint.x + (animatedItem.toPoint.x - animatedItem.atPoint.x) * progress,
animatedItem.atPoint.y + (animatedItem.toPoint.y - animatedItem.atPoint.y) * progress)
animatedItem.element.transform.baseVal.appendItem(transform)
break
case CHANGE_TYPE.appear:
animatedItem.element.style.opacity = Math.round(progress * 100) / 100
break
case CHANGE_TYPE.disappear:
animatedItem.element.style.opacity = Math.round((1 - progress) * 100) / 100
break
}
} else {
console.warn("animatedItem has no element", animatedItem)
}
})
this.view.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.animation, {
type: ANIMATION_EVENT_TYPE.frame,
progress: progress
})
}
static squareDistance(index1, index2) {
const file1 = index1 % 8
const rank1 = Math.floor(index1 / 8)
const file2 = index2 % 8
const rank2 = Math.floor(index2 / 8)
return Math.max(Math.abs(rank2 - rank1), Math.abs(file2 - file1))
}
}
export class PositionAnimationsQueue extends PromiseQueue {
constructor(chessboard) {
super()
this.chessboard = chessboard
}
async enqueuePositionChange(positionFrom, positionTo, animated) {
if(positionFrom.getFen() === positionTo.getFen()) {
// No diff to animate. Still go through the queue so the promise
// resolves after any animations already in flight (e.g. an
// earlier movePiece from a drag). See issue #154.
return super.enqueue(() => Promise.resolve())
} else {
return super.enqueue(() => new Promise((resolve) => {
let duration = animated ? this.chessboard.props.style.animationDuration : 0
if (this.queue.length > 0) {
duration = duration / (1 + Math.pow(this.queue.length / 5, 2))
}
new PositionsAnimation(this.chessboard.view,
positionFrom, positionTo, animated ? duration : 0,
() => {
if (this.chessboard.view) { // if destroyed, no view anymore
this.chessboard.view.redrawPieces(positionTo.squares)
}
resolve()
}
)
}))
}
}
async enqueueTurnBoard(position, color, animated) {
return super.enqueue(() => new Promise((resolve) => {
const emptyPosition = new Position(FEN.empty)
let duration = animated ? this.chessboard.props.style.animationDuration : 0
if(this.queue.length > 0) {
duration = duration / (1 + Math.pow(this.queue.length / 5, 2))
}
new PositionsAnimation(this.chessboard.view,
position, emptyPosition, animated ? duration : 0,
() => {
this.chessboard.state.orientation = color
this.chessboard.view.redrawBoard()
this.chessboard.view.redrawPieces(emptyPosition.squares)
new PositionsAnimation(this.chessboard.view,
emptyPosition, position, animated ? duration : 0,
() => {
this.chessboard.view.redrawPieces(position.squares)
resolve()
}
)
}
)
}))
}
}