Source code for scoreplotgraphicsview

'''
Score Chart
=============

|ScoreChart|

The score chart shows the material score (blue) representing material budget where 
the pieces represent as usual the values

* :math:`s_{pawn} = 1`
* :math:`s_{knight} = 3`
* :math:`s_{bishop} = 3`
* :math:`s_{rook} = 5`
* :math:`s_{queen} = 9`

If available, the scores emitted by a chess engine (red) are also shown.
The engine annotations require command tags according to `PGNExt`_ supplement, i.e. tags like [%eval *score*] or [%  *score*]
*score* is the score in pawns. 

The handling of positions close to mate vary from GUI to GUI. This GUI delivers for
a mate in *n* moves a score of

 * *mateScore - n*, if black is facing a mate in $n$
 * *n -mateScore*, if white is facing a mate in $n$

with :math:`mateScore = 100`.

The actual position of the game and the corresponding score values are indicated in the score graph.
It is updated when it changes.

It is possible to control the zoom of the *Score* axis. The GUI with an ongoing zoom process is shown here.

.. csv-table:: Zoom control by mouse
   :header: "Key", "Description"
   :widths: 30, 50

   :kbd:`mouse-left-press`       , begin zoom
   :kbd:`mouse-left-release`     , end zoom
   :kbd:`mouse-right-release`   , reset zoom

.. |ScoreChart| image:: scoreplotgraphicsview.png
  :width: 800
  :alt: Score Chart
.. _PGNExt: https://github.com/mliebelt/pgn-spec-commented/blob/main/pgn-spec-supplement.md
'''

from typing import Optional

import math
import sys, os, os.path

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import MzChess

if MzChess.useQt5():
 from PyQt5 import QtWidgets, QtGui, QtCore
 from PyQt5 import QtChart as QtCharts
 from PyQt5.QtWidgets import QShortcut
else:
 from PyQt6 import QtCharts, QtWidgets, QtGui, QtCore
 from PyQt6.QtGui import QShortcut

import chess, chess.pgn
from chessengine import PGNEval_REGEX

[docs]class ScorePlot(QtCharts.QChartView): '''Score plot object ''' piecePawnScoreDict = { chess.PAWN : 1, chess.KNIGHT : 3, chess.BISHOP : 3, chess.ROOK : 5, chess.QUEEN : 9 } seriesPens = { 'Material' : QtGui.QPen(QtGui.QBrush(QtCore.Qt.GlobalColor.blue), 1), 'Engine' : QtGui.QPen(QtGui.QBrush(QtCore.Qt.GlobalColor.red), 1), None : QtGui.QPen(QtGui.QBrush(QtCore.Qt.GlobalColor.gray), 2, QtCore.Qt.PenStyle.DotLine)} axesPen = QtGui.QPen(QtGui.QBrush(QtCore.Qt.GlobalColor.blue), 2) axesBrush = QtGui.QBrush(QtCore.Qt.GlobalColor.black) mateScore = 100 QtGui.QColor(QtCore.Qt.GlobalColor.blue) def __init__(self, parent : Optional[QtCore.QObject] = None) -> None: super(ScorePlot, self).__init__(parent) self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) self.setRubberBand(QtCharts.QChartView.RubberBand.VerticalRubberBand)
[docs] def setup(self, notifyGameNodeSelectedSignal : Optional[QtCore.pyqtSignal] = None): '''Set up of the score :param notifyGameNodeSelectedSignal: signal to be emitted if a game node is selected ''' if 'xAxis' in vars(self): return self.notifyGameNodeSelectedSignal = notifyGameNodeSelectedSignal if self.notifyGameNodeSelectedSignal is not None: scUp = QShortcut(self) scUp.setKey(QtCore.Qt.Key.Key_Up) scUp.activated.connect(self.on_sc_activated) scDown = QShortcut(self) scDown.setKey(QtCore.Qt.Key.Key_Down) scDown.activated.connect(self.on_sc_activated)
def _setupChart(self, notifyGameNodeSelectedSignal : Optional[QtCore.pyqtSignal] = None): self._createChart() self.xAxis = self._addAxis(QtCore.Qt.AlignmentFlag.AlignBottom, title = 'Move') self.yAxis = self._addAxis(QtCore.Qt.AlignmentFlag.AlignLeft, title = 'Score [pawns]') self.materialSeries = self._addSeries('Material', isLineSeries = True) self.engineSeries = self._addSeries('Engine', isLineSeries = True) self.selectedGameNode = None self.vLine = self._addSeries(None, isLineSeries = True) self.vLine.setPointsVisible(True) self.vLine.setPointLabelsVisible(True) self.vLine.setPointLabelsClipping(False) self.vLine.setPointLabelsFormat('@xPoint') vLineMarkers = self.chart.legend().markers(self.vLine) vLineMarkers[0].setVisible(False) self.meLabels = self._addSeries(None, isLineSeries = False) self.meLabels.setMarkerSize(10) self.meLabels.setPointsVisible(True) self.meLabels.setPointLabelsVisible(True) self.meLabels.setPointLabelsClipping(False) self.meLabels.setPointLabelsFormat('@yPoint') meLabelMarkers = self.chart.legend().markers(self.meLabels) meLabelMarkers[0].setVisible(False)
[docs] @staticmethod def minQtVersion(major : int, minor : int, patch : int = 0) -> bool: '''Checks whether the actual Qt-version is smaller or equal than the specified version :param major: specified major version number :param minor: specified minor version number :param patch: specified patch number :returns: True, if the actual Qt-version is smaller or equal than the specified version ''' qtMajor, qtMinor, qtPatch = list(map(int, QtCore.QT_VERSION_STR.split('.'))) return not (qtMajor < major \ or (qtMajor == major and qtMinor < minor) \ or (qtMajor == major and qtMinor == minor and qtPatch <= patch))
def interval(self, minV : float, maxV : float) -> int: assert maxV > minV if maxV - minV < 10: return 1 if maxV - minV < 100: return 10 return 100 def _createChart(self, bColor = QtCore.Qt.GlobalColor.lightGray, paColor = QtCore.Qt.GlobalColor.white, legendPosition = QtCore.Qt.AlignmentFlag.AlignBottom) -> None: self.chart = QtCharts.QChart() self.chart.setBackgroundBrush(bColor) self.chart.setPlotAreaBackgroundBrush(paColor) self.chart.setPlotAreaBackgroundVisible(True) self.chart.legend().setVisible(True) self.chart.legend().setAlignment(legendPosition) self.setChart(self.chart) def _addSeries(self, name : Optional[str] = None, isLineSeries : bool = True) -> QtCharts.QLineSeries: if isLineSeries: series = QtCharts.QLineSeries(self.chart) if name is not None: series.setName(name) series.setPen(self.seriesPens[name]) else: series = QtCharts.QScatterSeries(self.chart) series.setPen(self.axesPen) self.chart.addSeries(series) series.attachAxis(self.xAxis) series.attachAxis(self.yAxis) return series def _addTextLabel(self) -> QtWidgets.QGraphicsSimpleTextItem: label = self.scene().addSimpleText('') label.hide() return label def _addAxis(self, alignment : QtCore.Qt.AlignmentFlag, title: str = '') -> QtCharts.QValueAxis: axis = QtCharts.QValueAxis() axis.setLinePen(self.axesPen) axis.setLabelsBrush(self.axesBrush) axis.setGridLineVisible(True) axis.setMinorTickCount(5) if self.minQtVersion(5, 12): axis.setTickType(QtCharts.QValueAxis.TickType.TicksDynamic) axis.setTickAnchor(0) if len(title) > 0: axis.setTitleText(title) self.chart.addAxis(axis, alignment) return axis def _setRange(self, axis : QtCharts.QValueAxis, minV : float, maxV : float ) -> None: assert maxV > minV, '{} > {} required'.format(maxV, minV) if maxV - minV < 5: delta = 1 elif maxV - minV < 10: delta = 1 elif maxV - minV < 50: delta = 5 elif maxV - minV < 100: delta = 10 else: delta = 100 axis.setRange(minV, maxV) if self.minQtVersion(5, 12): axis.setTickInterval(delta) else: minV = (minV // delta) * delta maxV = (maxV // delta + 1) * delta axis.setMin(minV) axis.setMax(maxV) axis.setTickCount(int((maxV-minV) // delta) + 1) def _move(self, ply : int) -> float: return (ply + 1) / 2
[docs] def resetChart(self) -> None: '''Clears the chart ''' self.engineDict = dict() self.minY = float('inf') self.maxY = -float('inf') self.xAxis.setRange(0, 1) self.yAxis.setRange(-1, 1) self.materialSeries.clear() self.engineSeries.clear() self.vLine.clear() self.meLabels.clear() return
[docs] def copyAsBitmap(self, width : int = None) -> None: '''Copies the chart as a bitmap to clipboard :param width: width of the bitmap (Def: current width of the bounding box) ''' sourceRect = self.sceneBoundingRect().toRect() if width is None: width = sourceRect.width() width = int(width) height = int(sourceRect.height() * width / sourceRect.width()) bgColor = self.scene().views()[0].backgroundBrush().color().toRgb() plt = QtGui.QImage(width, height, QtGui.QImage.Format.Format_RGB32) plt.fill(bgColor) painter = QtGui.QPainter() painter.begin(plt) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) self.render(painter) painter.end() clipboard = QtWidgets.QApplication.clipboard() clipboard.setImage(plt)
[docs] def selectNodeItem(self, gameNode : chess.pgn.GameNode) -> None: '''Selects a game node :param gameNode: game node to be selected ''' if gameNode is None or gameNode.parent is None or not gameNode.is_mainline() or self.materialSeries.count() == 0: return relPly = gameNode.ply() - gameNode.game().ply() - 1 assert relPly < self.materialSeries.count() self.selectedGameNode = gameNode self.vLine.clear() self.meLabels.clear() xValue = self.materialSeries.at(relPly).x() self.vLine.append(xValue, self.yAxis.min()) self.vLine.append(xValue, 1000) self.vLine.show() self.meLabels.append(self.materialSeries.at(relPly)) if relPly in self.engineDict: relPly = self.engineDict[relPly] self.meLabels.append(self.engineSeries.at(relPly)) self.meLabels.show()
[docs] def addGameNodes(self, gameNode : chess.pgn.GameNode) -> None: '''Adds 1 or more nodes, parent node of first node must exist in the editor :param gameNode: game node to be added (must be main_variation !!) ''' if gameNode is None or not gameNode.is_mainline(): return if 'materialSeries' not in vars(self): self._setupChart() self.resetChart() self.chart.show() ply = gameNode.ply() xMin = (gameNode.game().ply() + 1) / 2 engineData = list() materialData = list() nMaterial = self.materialSeries.count() nEngine = self.engineSeries.count() while gameNode is not None: pieceMap = gameNode.board().piece_map() pawnScore = 0 for piece in list(pieceMap.values()): if piece.piece_type != chess.KING: if piece.color == chess.WHITE: pawnScore += self.piecePawnScoreDict[piece.piece_type] else: pawnScore -= self.piecePawnScoreDict[piece.piece_type] materialData.append(QtCore.QPointF((ply + 1)/2, pawnScore)) self.minY = min(self.minY, pawnScore) self.maxY = max(self.maxY, pawnScore) match = PGNEval_REGEX.search(gameNode.comment) if match is not None and (match.group(1) == '%' or match.group(1) == '%eval'): engineScore = match.group(2) if engineScore != 'None': try: engineScore = float(engineScore) except: engineScore = float(engineScore[1:]) engineScore = math.copysign(1,engineScore) * (self.mateScore - abs(engineScore)) engineData.append(QtCore.QPointF((ply + 1)/2, engineScore)) self.engineDict[nMaterial] = nEngine self.minY = min(self.minY, engineScore) self.maxY = max(self.maxY, engineScore) nEngine += 1 gameNode = gameNode.next() nMaterial += 1 ply += 1 if self.minY > self.maxY: return if len(materialData) > 0: self.materialSeries.append(materialData) if len(engineData) > 0: self.engineSeries.append(engineData) self._setRange(self.xAxis, xMin, max(ply / 2, 2)) if self.minY < self.maxY: self._setRange(self.yAxis, self.minY, self.maxY) else: self._setRange(self.yAxis, self.minY - 0.5, self.minY + 0.5) self.update()
def removeLastNode(self) -> None: if self.materialSeries.count() == 0: return ply = self.materialSeries.count() - 1 mMove = self.materialSeries.at(ply).x() self.materialSeries.remove(ply) ply += 2 self.minY = float('inf') self.maxY = -float('inf') for n in range(self.materialSeries.count()): pawnScore = self.materialSeries.at(n).y() self.minY = min(self.minY, pawnScore) self.maxY = max(self.maxY, pawnScore) if self.engineSeries.count() > 0: lastID = self.engineSeries.count() - 1 eMove = self.engineSeries.at(lastID).x() if mMove == eMove: self.engineSeries.remove(lastID) for n in range(self.engineSeries.count()): pawnScore = self.engineSeries.at(n).y() self.minY = min(self.minY, pawnScore) self.maxY = max(self.maxY, pawnScore) if self.minY > self.maxY: return self._setRange(self.xAxis, 1, max(ply / 2, 2)) if self.minY != self.maxY: self._setRange(self.yAxis, self.minY, self.maxY) else: self._setRange(self.yAxis, self.minY - 0.5, self.minY + 0.5) self.update()
[docs] def setGame(self, game : chess.pgn.Game) -> None: '''Sets a new game :param game: game node to be set ''' gameNode = game.next() if gameNode is None: if 'chart' in vars(self): self.chart.hide() return self.addGameNodes(gameNode) if 'Annotator' in game.headers: self.engineSeries.setName(game.headers['Annotator']) else: self.engineSeries.setName('Engine') self.selectNodeItem(gameNode)
@QtCore.pyqtSlot() def on_sc_activated(self): if self.selectedGameNode is not None: sendingSC = self.sender() if sendingSC.key() == QtCore.Qt.Key.Key_Down: newGameNode = self.selectedGameNode.next() else: newGameNode = self.selectedGameNode.parent if newGameNode is not None: self.notifyGameNodeSelectedSignal.emit(newGameNode)
[docs] @QtCore.pyqtSlot(QtGui.QMouseEvent) def mouseReleaseEvent(self, e): plies = self.materialSeries.count() if plies == 0: return super(ScorePlot, self).mouseReleaseEvent(e) if e.button() == QtCore.Qt.MouseButton.RightButton: self.chart.zoomReset() self._setRange(self.xAxis, 1, plies / 2) self._setRange(self.yAxis, self.yAxis.min(), self.yAxis.max()) self.selectNodeItem(self.selectedGameNode)
if __name__ == "__main__": import io, sys from pgnParse import read_game class _My(QtWidgets.QMainWindow): def __init__(self, game): super().__init__() self.setWindowTitle('self.chart Formatting Demo') self.plot = ScorePlot(self) self.setCentralWidget(self.plot) self.resize(1200, 800) def setup(self): self.plot.setup() self.plot.setGame(game) gameNode = game for i in range(10): gameNode = gameNode.next() self.plot.selectNodeItem(gameNode) ps = "C:/Users/Reinh/OneDrive/Dokumente/Schach/ps210105.pgn" with open(ps, mode = 'r', encoding = 'utf-8') as f: newData = f.read() pgn = io.StringIO(newData) game = read_game(pgn) app = QtWidgets.QApplication([]) plotWindow = _My(game) plotWindow.show() plotWindow.setup() sys.exit(app.exec())