| |
| |
| |
| export class GameGraph { |
| static canvas = null; |
| static ctx = null; |
| static analysis = null; |
| static hoverIndex = -1; |
| static isHovering = false; |
| static hoverX = 0; |
| static hoverY = 0; |
| static currentMove = null; |
| static scaleFactor = 2; |
| static initialized = false; |
| static clickCallback = null; |
|
|
| static get canvasElement() { |
| if (!this.canvas) { |
| this.canvas = document.getElementById('game-analysis-graph'); |
| this.ctx = this.canvas?.getContext('2d'); |
| this.setupEventListeners(); |
| } |
| return this.canvas; |
| } |
|
|
| static setupEventListeners() { |
| if (!this.canvas || this.initialized) return; |
| |
| this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); |
| this.canvas.addEventListener('mouseenter', () => { |
| this.isHovering = true; |
| this.canvas.style.cursor = 'pointer'; |
| this.render(); |
| }); |
| this.canvas.addEventListener('mouseleave', () => { |
| this.isHovering = false; |
| this.canvas.style.cursor = 'default'; |
| this.render(); |
| }); |
| this.canvas.addEventListener('click', this.handleClick.bind(this)); |
| this.canvas.addEventListener('touchstart', this.handleClick.bind(this)); |
| |
| window.addEventListener('resize', () => this.render()); |
| this.initialized = true; |
| this.render(); |
| } |
|
|
| static setAnalysis(analysis) { |
| this.analysis = analysis; |
| this.render(); |
| } |
|
|
| static setClickCallback(callback) { |
| this.clickCallback = callback; |
| } |
| |
| static handleMouseMove(event) { |
| if (!this.analysis?.moves?.length) return; |
| |
| const rect = this.canvasElement.getBoundingClientRect(); |
| const x = (event.clientX - rect.left) * this.scaleFactor; |
| const y = (event.clientY - rect.top) * this.scaleFactor; |
| |
| this.hoverX = x; |
| this.hoverY = y; |
| |
| const width = this.canvasElement.width; |
| const moves = this.analysis.moves; |
| const total = moves.length; |
| const increment = width / total; |
| |
| const moveIndex = Math.min(Math.max(0, Math.floor(x / increment)), total - 1); |
| |
| if (this.hoverIndex !== moveIndex) { |
| this.hoverIndex = moveIndex; |
| this.render(); |
| } |
| } |
|
|
| static handleClick(event) { |
| if (!this.analysis?.moves?.length || !this.clickCallback) return; |
| |
| const rect = this.canvasElement.getBoundingClientRect(); |
| const x = (event.clientX - rect.left) * this.scaleFactor; |
| |
| const width = this.canvasElement.width; |
| const moves = this.analysis.moves; |
| const total = moves.length; |
| const increment = width / total; |
| |
| const moveIndex = Math.min(Math.max(0, Math.floor(x / increment)), total - 1); |
| const clickedMove = moves[moveIndex]; |
| |
| if (clickedMove) { |
| this.clickCallback(clickedMove); |
| } |
| } |
|
|
| static updateCurrentMoveNumber(moveNumber) { |
| this.currentMove = moveNumber; |
| this.render(); |
| } |
|
|
| static render() { |
| if (!this.canvasElement || !this.ctx) return; |
|
|
| const canvas = this.canvasElement; |
| const ctx = this.ctx; |
| |
| const height = parseInt($(".game-graph").css('height')) * this.scaleFactor; |
| const width = parseInt($(".game-graph").css('width')) * this.scaleFactor; |
|
|
| canvas.width = canvas.clientWidth * this.scaleFactor; |
| canvas.height = canvas.clientHeight * this.scaleFactor; |
|
|
| |
| ctx.fillStyle = '#1a1a1a'; |
| ctx.fillRect(0, 0, width, height); |
|
|
| if (!this.analysis || !this.analysis.moves || this.analysis.moves.length === 0) { |
| |
| ctx.fillStyle = '#dddddd'; |
| ctx.fillRect(0, height/2, width, height); |
|
|
| ctx.fillStyle = 'grey'; |
| ctx.fillRect(0, height / 2 - 1, width, 2); |
| return; |
| } |
|
|
| const moves = this.analysis.moves; |
| const total = moves.length; |
| |
| if (total > 0) { |
| const increment = width / total; |
|
|
| ctx.beginPath(); |
| ctx.moveTo(-3, height); |
| ctx.lineTo(0, height/2); |
| |
| for (let i = 0; i < total; i++) { |
| const move = moves[i]; |
| const offset = increment * i; |
| const x = offset + 3; |
| const y = height / 100 * (move.graph); |
| |
| ctx.lineTo(x, y); |
|
|
| if (i === total-1) { |
| ctx.lineTo(increment * (total-1) + 50, y); |
| } |
| } |
| |
| ctx.lineTo(increment * (total-1) + 50, height); |
| |
| |
| ctx.fillStyle = '#dddddd'; |
| ctx.fill(); |
| |
| |
| ctx.fillStyle = '#80808075'; |
| ctx.fillRect(0, height / 2 - 1, width, 2 * this.scaleFactor); |
|
|
| if (this.currentMove) { |
| const move = moves[2 * this.currentMove - 2]; |
| if (!move) return; |
|
|
| const offset = increment * this.currentMove * 2 - increment * 2; |
| ctx.fillStyle = '#99999975'; |
| ctx.fillRect(offset - this.scaleFactor, 0, 2 * this.scaleFactor, height); |
| |
| const y = height / 100 * (move.graph); |
|
|
| |
| ctx.fillStyle = move.classification.color; |
| ctx.beginPath(); |
| ctx.arc(offset, y, 4 * this.scaleFactor, 0, Math.PI * 2 * this.scaleFactor); |
| ctx.fill(); |
| } |
|
|
| |
| if (this.isHovering && this.hoverIndex >= 0 && this.hoverIndex < total) { |
| const move = moves[this.hoverIndex]; |
|
|
| const offset = increment * this.hoverIndex; |
| const x = offset; |
| const y = height/100 * (move.graph); |
|
|
| |
| ctx.fillStyle = '#99999975'; |
| ctx.fillRect(x - this.scaleFactor, 0, 2 * this.scaleFactor, height); |
| |
| |
| ctx.fillStyle = move.classification.color; |
|
|
| ctx.beginPath(); |
| ctx.arc(x, y, 4 * this.scaleFactor, 0, Math.PI * 2 * this.scaleFactor); |
| ctx.fill(); |
|
|
| this.drawEvaluationPopup(move, x, y - 22 * this.scaleFactor); |
| } |
|
|
| } |
| } |
| |
| static drawEvaluationPopup(move, x, y) { |
| const ctx = this.ctx; |
| const popupWidth = 40 * this.scaleFactor; |
| const popupHeight = 22 * this.scaleFactor; |
|
|
| let evalText; |
| const bestLine = move.lines.find(line => line.id === 1); |
| if (bestLine) { |
| const evalValue = bestLine.score / 100; |
| const isMate = bestLine.type === 'mate'; |
| evalText = isMate ? `M${Math.abs(bestLine.score)}` : evalValue > 0 ? `+${evalValue.toFixed(2)}` : evalValue.toFixed(2); |
| } else { |
| evalText = "0-1" |
| } |
|
|
| |
| const adjustedX = Math.min(Math.max(x - popupWidth/2, 10), this.canvasElement.width * this.scaleFactor - popupWidth - 10); |
| const adjustedY = Math.min(Math.max(y - popupHeight/2, 50), this.canvasElement.height * this.scaleFactor - popupHeight - 10); |
|
|
| |
| ctx.fillStyle = 'rgba(58, 58, 58, 0.95)'; |
| this.roundRect(ctx, adjustedX, adjustedY, popupWidth, popupHeight, 5, true); |
| |
| |
| ctx.fillStyle = 'white'; |
| ctx.font = `bold ${12 * this.scaleFactor}px Arial`; |
| ctx.textAlign = 'center'; |
| ctx.fillText(`${evalText}`, adjustedX + popupWidth/2, adjustedY + 15 * this.scaleFactor); |
| } |
| |
| static roundRect(ctx, x, y, width, height, radius, fill, stroke) { |
| if (typeof radius === 'undefined') { |
| radius = 5; |
| } |
| |
| ctx.beginPath(); |
| ctx.moveTo(x + radius, y); |
| ctx.lineTo(x + width - radius, y); |
| ctx.quadraticCurveTo(x + width, y, x + width, y + radius); |
| ctx.lineTo(x + width, y + height - radius); |
| ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); |
| ctx.lineTo(x + radius, y + height); |
| ctx.quadraticCurveTo(x, y + height, x, y + height - radius); |
| ctx.lineTo(x, y + radius); |
| ctx.quadraticCurveTo(x, y, x + radius, y); |
| ctx.closePath(); |
| |
| if (fill) { |
| ctx.fill(); |
| } |
| |
| if (stroke) { |
| ctx.stroke(); |
| } |
| } |
| } |