nmreval/src/gui_qt/graphs/graphwindow.py
2023-09-19 16:32:54 +02:00

789 lines
27 KiB
Python

from __future__ import annotations
import itertools
import os
import uuid
from math import isfinite
from pathlib import Path
import numpy as np
from numpy import errstate, floor, log10
from pyqtgraph import GraphicsObject, getConfigOption, mkColor
from nmreval.lib.logger import logger
from nmreval.utils.text import convert
from ..io.filedialog import FileDialog
from ..lib.pg_objects import LegendItemBlock, RegionItem
from ..Qt import QtCore, QtWidgets, QtGui
from .._py.graph import Ui_GraphWindow
from ..lib.iconloading import make_action_icons
from ..lib.configurations import GraceMsgBox
class QGraphWindow(QtWidgets.QGraphicsView, Ui_GraphWindow):
mousePositionChanged = QtCore.pyqtSignal(float, float)
mouseDoubleClicked = QtCore.pyqtSignal()
positionClicked = QtCore.pyqtSignal(tuple, bool)
aboutToClose = QtCore.pyqtSignal(list)
counter = itertools.count()
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setupUi(self)
self._bgcolor = mkColor(getConfigOption('background'))
self._fgcolor = mkColor(getConfigOption('foreground'))
self._prev_colors = mkColor('k'), mkColor('w')
self._init_gui()
make_action_icons(self)
self.id = str(uuid.uuid4())
self.sets = []
self._active = []
self.real_plots = {}
self.imag_plots = {}
self.error_plots = {}
self._special_needs = []
self._external_items = []
self.closable = True
self._block = False
self.log = [False, False]
self.scene = self.plotItem.scene()
self.scene.sigMouseMoved.connect(self.move_mouse)
self.checkBox.stateChanged.connect(lambda x: self.legend.setVisible(x == QtCore.Qt.Checked))
self.label_button.toggled.connect(lambda x: self.label_widget.setVisible(x))
self.limit_button.toggled.connect(lambda x: self.limit_widget.setVisible(x))
self.gridbutton.toggled.connect(lambda x: self.graphic.showGrid(x=x, y=x))
self.logx_button.toggled.connect(lambda x: self.set_logmode(xmode=x))
self.logy_button.toggled.connect(lambda x: self.set_logmode(ymode=x))
self.graphic.plotItem.vb.sigRangeChanged.connect(self.update_limits)
self.listWidget.itemChanged.connect(self.show_legend)
# reconnect "Export..." in context menu to our function
self.scene.contextMenu[0].disconnect()
self.scene.contextMenu[0].triggered.connect(self.export_dialog)
self.bwbutton.toggled.connect(self.change_background)
def _init_gui(self):
self.setWindowTitle('Graph ' + str(next(QGraphWindow.counter)))
self.label_widget.hide()
self.limit_widget.hide()
self.listWidget.hide()
self.checkBox.hide()
self.plotItem = self.graphic.plotItem
for orient in ['top', 'bottom', 'left', 'right']:
self.plotItem.showAxis(orient)
ax = self.plotItem.getAxis(orient)
ax.enableAutoSIPrefix(False)
if orient == 'top':
ax.setStyle(showValues=False)
ax.setHeight(10)
elif orient == 'right':
ax.setStyle(showValues=False)
ax.setWidth(10)
self.legend = LegendItemBlock(offset=(20, 20))
self.legend.setParentItem(self.plotItem.vb)
self.plotItem.legend = self.legend
self.legend.setVisible(True)
self.plotItem.setMenuEnabled(False, True)
self.plotItem.ctrl.logXCheck.blockSignals(True)
self.plotItem.ctrl.logYCheck.blockSignals(True)
for lineedit in [self.xmin_lineedit, self.xmax_lineedit, self.ymin_lineedit, self.ymax_lineedit]:
lineedit.setValidator(QtGui.QDoubleValidator())
def __contains__(self, item: str):
return item in self.sets
def __iter__(self):
return iter(self.active)
def __len__(self):
return len(self._active)
def curves(self) -> tuple:
for set_id in self.sets:
if set_id in self._active:
if self.real_button.isChecked():
if self.error_plots[set_id] is not None:
yield self.real_plots[set_id], self.error_plots[set_id]
else:
yield self.real_plots[set_id],
if self.imag_button.isChecked() and self.imag_plots[set_id] is not None:
yield self.imag_plots[set_id],
@property
def title(self):
return self.windowTitle()
@title.setter
def title(self, value):
self.setWindowTitle(str(value))
@property
def ranges(self) -> tuple:
r = self.plotItem.getViewBox().viewRange()
for i in [0, 1]:
if self.log[i]:
tmp = [np.nan, np.nan]
for j, x in enumerate(r[i]):
try:
tmp[j] = 10**min(x, 199)
except OverflowError:
pass
r[i] = tuple(tmp)
else:
r[i] = tuple(r[i])
return tuple(r)
@property
def active(self) -> list:
return [set_id for set_id in self.sets if set_id in self._active]
@active.setter
def active(self, value: list):
self._active = value
def block(self, state: bool):
self._block = state
if not self._block:
self.graphic.enableAutoRange()
self._update_zorder()
self.show_legend()
else:
self.graphic.disableAutoRange()
def add(self, name: str | list, plots: list):
if isinstance(name, str):
name = [name]
plots = [plots]
toplevel = len(self.sets)
self.listWidget.blockSignals(True)
for (real_plot, imag_plot, err_plot), n in zip(plots, name):
self.sets.append(n)
if real_plot:
real_plot.setZValue(2*toplevel+1)
if imag_plot:
imag_plot.setZValue(2*toplevel+1)
if err_plot:
err_plot.setZValue(2*toplevel)
self.real_plots[n] = real_plot
self.imag_plots[n] = imag_plot
self.error_plots[n] = err_plot
list_item = QtWidgets.QListWidgetItem(real_plot.opts.get('name', ''))
list_item.setData(QtCore.Qt.UserRole, n)
list_item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsUserCheckable)
list_item.setCheckState(QtCore.Qt.Checked)
self.listWidget.addItem(list_item)
toplevel += 1
self.listWidget.blockSignals(False)
if len(name) < 200:
self.show_item(name)
else:
QtWidgets.QMessageBox.warning(self, 'Display disabled',
'If more than 200 sets are added at once, they are not displayed to avoid major performance issues.\n'
'Building the data tree may still take some time, the checkmark on the left is invalid.\n'
'Please display them manually in smaller batches, thank you!')
def remove(self, name: str | list):
if isinstance(name, str):
name = [name]
for n in name:
self.sets.remove(n)
for plot in [self.real_plots, self.imag_plots, self.error_plots]:
self.graphic.removeItem(plot[n])
if n in self._active:
self._active.remove(n)
# remove from label list
self.listWidget.blockSignals(True)
for i in range(self.listWidget.count()-1, 0, -1):
item = self.listWidget.item(i)
if item.data(QtCore.Qt.UserRole) in name:
self.listWidget.takeItem(i)
self.listWidget.blockSignals(False)
if not self._block:
self._update_zorder()
self.show_legend()
def move_sets(self, sets: list, position: int):
move_plots = []
move_items = []
self.listWidget.blockSignals(True)
for s in sets:
idx = self.sets.index(s)
move_plots.append(self.sets.pop(idx))
move_items.append(self.listWidget.takeItem(idx))
if position == -1:
self.sets.extend(move_plots)
for it in move_items:
self.listWidget.addItem(it)
else:
self.sets = self.sets[:position] + move_plots + self.sets[position:]
for it in move_items[::-1]:
self.listWidget.insertItem(position, it)
self.listWidget.blockSignals(False)
self._update_zorder()
def show_item(self, idlist: list):
if len(self.sets) == 0:
return
for a in idlist:
if a not in self._active:
self._active.append(a)
for (bttn, plot_dic) in [
(self.real_button, self.real_plots),
(self.imag_button, self.imag_plots),
(self.error_button, self.error_plots),
]:
if bttn.isChecked():
item = plot_dic[a]
if (item is not None) and (item not in self.graphic.items()):
self.graphic.addItem(item)
self.show_legend()
def hide_item(self, idlist: list):
if len(self.sets) == 0:
return
for r in idlist:
if r in self._active:
self._active.remove(r)
for plt in [self.real_plots, self.imag_plots, self.error_plots]:
item = plt[r]
if item in self.graphic.items():
self.graphic.removeItem(item)
@QtCore.pyqtSlot(bool, name='on_imag_button_toggled')
@QtCore.pyqtSlot(bool, name='on_real_button_toggled')
def set_imag_visible(self, visible: bool):
if self.sender() == self.real_button:
plots = self.real_plots
if self.error_button.isChecked() and not visible:
self.error_button.setChecked(False)
else:
plots = self.imag_plots
if visible:
func = self.graphic.addItem
else:
func = self.graphic.removeItem
for a in self._active:
item = plots[a]
if item is not None:
func(item)
self.show_legend()
@QtCore.pyqtSlot(bool, name='on_error_button_toggled')
def show_errorbar(self, visible: bool):
if visible and not self.real_button.isChecked():
# no errorbars without points
self.error_button.blockSignals(True)
self.error_button.setChecked(False)
self.error_button.blockSignals(False)
return
if visible:
for a in self._active:
item = self.error_plots[a]
if (item is not None) and (item not in self.graphic.items()):
self.graphic.addItem(item)
else:
for a in self._active:
item = self.error_plots[a]
if (item is not None) and (item in self.graphic.items()):
self.graphic.removeItem(item)
def add_external(self, item):
if isinstance(item, RegionItem) and item.first:
# Give regions nice values on first addition to a graph
x, _ = self.ranges
if item.mode == 'mid':
onset = item.getRegion()[0]
if self.log[0]:
delta = log10(x[1]/x[0])/20
span = (onset / 10**delta , onset * 10**delta)
else:
delta = x[1]-x[0]
span = (onset-delta/20, onset + delta/20)
elif item.mode == 'half':
span = (0.75*x[0]+0.25*x[1], 0.25*x[0]+0.75*x[1])
else:
span = item.getRegion()
item.setRegion(span)
item.first = False
if item in self.graphic.items():
return False
if not hasattr(item, 'setLogMode'):
self._special_needs.append(item)
self._external_items.append(item)
self.graphic.addItem(item)
item.setZValue(1000)
return True
@QtCore.pyqtSlot(GraphicsObject)
def remove_external(self, item):
if item in self._external_items:
self._external_items.remove(item)
if item in self._special_needs:
self._special_needs.remove(item)
if item not in self.graphic.items():
return False
self.graphic.removeItem(item)
return True
def closeEvent(self, evt: QtGui.QCloseEvent):
if not self.closable:
evt.ignore()
return
res = QtWidgets.QMessageBox.Yes
if len(self.sets) != 0:
res = QtWidgets.QMessageBox.question(self, 'Plot not empty', 'Graph is not empty. Deleting with all data?',
QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No)
if res == QtWidgets.QMessageBox.Yes:
self.aboutToClose.emit([self.id])
evt.accept()
else:
evt.ignore()
def move_mouse(self, evt):
vb = self.plotItem.getViewBox()
if self.plotItem.sceneBoundingRect().contains(evt):
pos = vb.mapSceneToView(evt)
if self.log[0]:
try:
_x = 10**(pos.x())
except OverflowError:
_x = pos.x()
else:
_x = pos.x()
if self.log[1]:
try:
_y = 10**(pos.y())
except OverflowError:
_y = pos.y()
else:
_y = pos.y()
self.mousePositionChanged.emit(_x, _y)
@QtCore.pyqtSlot(name='on_title_lineedit_returnPressed')
@QtCore.pyqtSlot(name='on_xaxis_linedit_returnPressed')
@QtCore.pyqtSlot(name='on_yaxis_linedit_returnPressed')
def labels_changed(self):
label = {self.title_lineedit: 'title', self.xaxis_linedit: 'x', self.yaxis_linedit: 'y'}[self.sender()]
self.set_label(**{label: self.sender().text()})
def set_label(self, x=None, y=None, title=None):
if title is not None:
self.plotItem.setTitle(convert(title, old='tex', new='html'), **{'size': '10pt', 'color': self._fgcolor})
if x is not None:
self.plotItem.setLabel('bottom', convert(x, old='tex', new='html'),
**{'font-size': '10pt', 'color': self._fgcolor.name()})
if y is not None:
self.plotItem.setLabel('left', convert(y, old='tex', new='html'),
**{'font-size': '10pt', 'color': self._fgcolor.name()})
def set_logmode(self, xmode: bool = None, ymode: bool = None):
r = self.ranges
self.plotItem.setXRange(*r[0])
self.plotItem.setYRange(*r[1])
if xmode is None:
xmode = self.plotItem.ctrl.logXCheck.isChecked()
else:
self.plotItem.ctrl.logXCheck.setCheckState(xmode)
if ymode is None:
ymode = self.plotItem.ctrl.logYCheck.isChecked()
else:
self.plotItem.ctrl.logYCheck.setCheckState(ymode)
self.log = [xmode, ymode]
for item in self._special_needs:
item.logmode[0] = self.log[:]
self.plotItem.updateLogMode()
self.set_range(x=r[0], y=r[1])
self.plotItem.enableAutoRange()
def enable_picking(self, enabled: bool):
if enabled:
self.scene.sigMouseClicked.connect(self.position_picked)
else:
try:
self.scene.sigMouseClicked.disconnect()
except TypeError:
pass
def position_picked(self, evt):
vb = self.graphic.plotItem.vb
if self.graphic.plotItem.sceneBoundingRect().contains(evt.scenePos()) and evt.button() == 1:
pos = vb.mapSceneToView(evt.scenePos())
_x, _y = pos.x(), pos.y()
if self.log[0]:
_x = 10**_x
if self.log[1]:
_y = 10**_y
self.positionClicked.emit((_x, _y), evt.double())
@QtCore.pyqtSlot(name='on_apply_button_clicked')
def set_range(self, x: tuple = None, y: tuple = None):
if x is None:
x = float(self.xmin_lineedit.text()), float(self.xmax_lineedit.text())
x = min(x), max(x)
if y is None:
y = float(self.ymin_lineedit.text()), float(self.ymax_lineedit.text())
y = min(y), max(y)
for log, xy, func in zip(self.log, (x, y), (self.graphic.setXRange, self.graphic.setYRange)):
if log:
with errstate(all='ignore'):
xy = [log10(val) for val in xy]
if not isfinite(xy[1]):
xy = [-1, 1]
elif not isfinite(xy[0]):
xy[0] = xy[1]-4
func(xy[0], xy[1], padding=0)
@QtCore.pyqtSlot(object)
def update_limits(self, _):
r = self.ranges
self.xmin_lineedit.setText('%.5g' % r[0][0])
self.xmax_lineedit.setText('%.5g' % r[0][1])
self.ymin_lineedit.setText('%.5g' % r[1][0])
self.ymax_lineedit.setText('%.5g' % r[1][1])
def _update_zorder(self):
for i, sid in enumerate(self.sets):
plt = self.real_plots[sid]
if plt.zValue() != 2*i+1:
plt.setZValue(2*i+1)
if self.imag_plots[sid] is not None:
self.imag_plots[sid].setZValue(2*i+1)
if self.error_plots[sid] is not None:
self.error_plots[sid].setZValue(2*i)
self.show_legend()
@QtCore.pyqtSlot(bool, name='on_legend_button_toggled')
def show_legend_item_list(self, visible: bool):
self.listWidget.setVisible(visible)
self.checkBox.setVisible(visible)
def update_legend(self, sid, name):
if self._block:
return
self.listWidget.blockSignals(True)
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
if item.data(QtCore.Qt.UserRole) == sid:
item.setText(convert(name, old='tex', new='html'))
self.listWidget.blockSignals(False)
self.show_legend()
def show_legend(self):
if not self.legend.isVisible():
return
self.legend.clear()
for i, sid in enumerate(self.sets):
item = self.real_plots[sid]
other_item = self.imag_plots[sid]
# should legend be visible? is either real part or imaginary part shown?
if self.listWidget.item(i).checkState():
if item in self.graphic.items():
self.legend.addItem(item, convert(item.opts.get('name', ''), old='tex', new='html'))
elif other_item in self.graphic.items():
self.legend.addItem(other_item, convert(other_item.opts.get('name', ''), old='tex', new='html'))
def export_dialog(self, _=None):
filters = 'All files (*.*);;AGR (*.agr);;SVG (*.svg);;PDF (*.pdf)'
for imgformat in QtGui.QImageWriter.supportedImageFormats():
str_format = imgformat.data().decode('utf-8')
filters += ';;' + str_format.upper() + ' (*.' + str_format + ')'
outfile = None
f = FileDialog(caption='Export graphic', filter=filters, mode='save')
f.setOption(FileDialog.DontConfirmOverwrite)
mode = f.exec()
if mode == QtWidgets.QDialog.Accepted:
outfile = f.save_file()
if outfile:
self.export(outfile)
def export(self, outfile: Path):
suffix = outfile.suffix
if suffix == '':
QtWidgets.QMessageBox.warning(self, 'No file extension',
'No file extension found, graphic was not saved.')
return
if suffix == '.agr':
res = 0
if outfile.exists():
res = GraceMsgBox(outfile, parent=self).exec()
if res == -1:
return
opts = self.export_graphics()
from ..io.exporters import GraceExporter
if res == 0:
mode = 'w'
elif res == 1:
mode = 'a'
else:
mode = res-2
GraceExporter(opts).export(outfile, mode=mode)
else:
if os.path.exists(outfile):
if QtWidgets.QMessageBox.warning(self, 'Export graphic',
f'{os.path.split(outfile)[1]} already exists.\n'
f'Do you REALLY want to replace it?',
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.No:
return
bg_color = self._bgcolor
fg_color = self._fgcolor
self.set_color(foreground='k', background='w')
if suffix == '.pdf':
from ..io.exporters import PDFPrintExporter
PDFPrintExporter(self.graphic).export(str(outfile))
elif suffix == '.svg':
from pyqtgraph.exporters import SVGExporter
SVGExporter(self.scene).export(str(outfile))
else:
from pyqtgraph.exporters import ImageExporter
ImageExporter(self.scene).export(str(outfile))
self.set_color(foreground=fg_color, background=bg_color)
def export_graphics(self) -> dict:
dic = self.get_state()
dic['items'] = []
in_legend = []
for item in self.curves():
plot_item = item[0]
legend_shown = False
for sample, _ in self.legend.items:
if sample.item is plot_item:
legend_shown = True
break
in_legend.append(legend_shown)
try:
item_dic = plot_item.get_data_opts()
except Exception as e:
logger.exception(f'{item} could not exported because {e.args}')
continue
if len(item) == 2:
# plot can show errorbars
item_dic['yerr'] = item[1].opts['topData']
if item_dic:
dic['items'].append(item_dic)
for item in self._external_items:
try:
dic['items'].append(item.get_data_opts())
except Exception as e:
logger.exception(f'{item} could not be exported because {e.args}')
continue
in_legend.append(False)
dic['in_legend'] = in_legend
return dic
def get_state(self) -> dict:
dic = {
'id': self.id,
'limits': (self.ranges[0], self.ranges[1]),
'ticks': (),
'labels': (self.plotItem.getAxis('bottom').labelText,
self.plotItem.getAxis('left').labelText,
self.plotItem.titleLabel.text,
self.title),
'log': self.log,
'grid': self.gridbutton.isChecked(),
'legend': self.legend.isVisible(),
'plots': (self.real_button.isChecked(), self.imag_button.isChecked(), self.error_button.isChecked()),
'children': self.sets,
'active': self._active,
}
in_legend = []
for i in range(self.listWidget.count()):
in_legend.append(bool(self.listWidget.item(i).checkState()))
dic['in_legend'] = in_legend
# bottomLeft gives top left corner
l_topleft = self.plotItem.vb.itemBoundingRect(self.legend).bottomLeft()
legend_origin = [l_topleft.x(), l_topleft.y()]
for i in [0, 1]:
if self.log[i]:
legend_origin[i] = 10**legend_origin[i]
dic['legend_pos'] = legend_origin
for i, ax in enumerate(['bottom', 'left']):
if self.log[i]:
major = 10
minor = 9
else:
vmin, vmax = dic['limits'][i][0], dic['limits'][i][1]
dist = vmax - vmin
scale = 10**floor(log10(abs(dist)))
steps = [0.1, 0.2, 0.25, 0.5, 1., 2., 2.5, 5., 10., 20., 50., 100.]
for step_i in steps:
if dist / step_i / scale <= 10:
break
major = step_i * scale
minor = 1
dic['ticks'] += (major, minor),
return dic
@staticmethod
def set_state(state):
graph = QGraphWindow()
graph.id = state.get('id', graph.id)
graph.plotItem.setLabel('bottom', state['labels'][0], **{'font-size': '10pt', 'color': graph._fgcolor.name()})
graph.plotItem.setLabel('left', state['labels'][1], **{'font-size': '10pt', 'color': graph._fgcolor.name()})
graph.plotItem.setTitle(state['labels'][2], **{'size': '10pt', 'color': graph._fgcolor.name()})
graph.setWindowTitle(state['labels'][3])
graph.graphic.showGrid(x=state['grid'], y=state['grid'])
graph.checkBox.setCheckState(QtCore.Qt.Checked if state['legend'] else QtCore.Qt.Unchecked)
graph.real_button.setChecked(state['plots'][0])
graph.imag_button.setChecked(state['plots'][1])
graph.error_button.setChecked(state['plots'][2])
graph.set_range(x=state['limits'][0], y=state['limits'][1])
graph.logx_button.setChecked(state['log'][0])
graph.logy_button.setChecked(state['log'][1])
return graph
def set_color(self, foreground=None, background=None):
if background is not None:
self._bgcolor = mkColor(background)
self.graphic.setBackground(self._bgcolor)
self.legend.setBrush(self._bgcolor)
if foreground is not None:
self._fgcolor = mkColor(foreground)
for ax in ['left', 'bottom']:
pen = self.plotItem.getAxis(ax).pen()
pen.setColor(self._fgcolor)
self.plotItem.getAxis(ax).setPen(pen)
self.plotItem.getAxis(ax).setTextPen(pen)
self.legend.setLabelTextColor(self._fgcolor)
if self.legend.isVisible():
self.show_legend()
title = self.plotItem.titleLabel.text
if title is not None:
self.plotItem.setTitle(title, **{'size': '10pt', 'color': self._fgcolor})
x = self.plotItem.getAxis('bottom').labelText
if x is not None:
self.plotItem.setLabel('bottom', x, **{'font-size': '10pt', 'color': self._fgcolor.name()})
y = self.plotItem.getAxis('left').labelText
if y is not None:
self.plotItem.setLabel('left', y, **{'font-size': '10pt', 'color': self._fgcolor.name()})
def change_background(self, _):
temp = self._fgcolor, self._bgcolor
self.set_color(foreground=self._prev_colors[0], background=self._prev_colors[1])
self._prev_colors = temp