1import itertools 2 3import numpy as np 4 5from AnyQt.QtCore import ( 6 QRectF, QLineF, QObject, QEvent, Qt, pyqtSignal as Signal 7) 8from AnyQt.QtGui import QTransform, QFontMetrics, QStaticText, QBrush, QPen, \ 9 QFont 10from AnyQt.QtWidgets import ( 11 QGraphicsLineItem, QGraphicsSceneMouseEvent, QPinchGesture, 12 QGraphicsItemGroup, QWidget) 13 14import pyqtgraph as pg 15import pyqtgraph.functions as fn 16from pyqtgraph.graphicsItems.LegendItem import ItemSample 17from pyqtgraph.graphicsItems.ScatterPlotItem import drawSymbol 18 19from Orange.widgets.utils.plot import SELECT, PANNING, ZOOMING 20 21 22class TextItem(pg.TextItem): 23 if not hasattr(pg.TextItem, "setAnchor"): 24 # Compatibility with pyqtgraph <= 0.9.10; in (as of yet unreleased) 25 # 0.9.11 the TextItem has a `setAnchor`, but not `updateText` 26 def setAnchor(self, anchor): 27 self.anchor = pg.Point(anchor) 28 self.updateText() 29 30 def get_xy(self): 31 point = self.pos() 32 return point.x(), point.y() 33 34 35class AnchorItem(pg.GraphicsObject): 36 def __init__(self, parent=None, line=QLineF(), text="", **kwargs): 37 super().__init__(parent, **kwargs) 38 self._text = text 39 self.setFlag(pg.GraphicsObject.ItemHasNoContents) 40 41 self._spine = QGraphicsLineItem(line, self) 42 angle = line.angle() 43 44 self._arrow = pg.ArrowItem(parent=self, angle=0) 45 self._arrow.setPos(self._spine.line().p2()) 46 self._arrow.setRotation(angle) 47 self._arrow.setStyle(headLen=10) 48 49 self._label = TextItem(text=text, color=(10, 10, 10)) 50 self._label.setParentItem(self) 51 self._label.setPos(*self.get_xy()) 52 53 if parent is not None: 54 self.setParentItem(parent) 55 56 def get_xy(self): 57 point = self._spine.line().p2() 58 return point.x(), point.y() 59 60 def setFont(self, font): 61 self._label.setFont(font) 62 63 def setText(self, text): 64 if text != self._text: 65 self._text = text 66 self._label.setText(text) 67 self._label.setVisible(bool(text)) 68 69 def text(self): 70 return self._text 71 72 def setLine(self, *line): 73 line = QLineF(*line) 74 if line != self._spine.line(): 75 self._spine.setLine(line) 76 self.__updateLayout() 77 78 def line(self): 79 return self._spine.line() 80 81 def setPen(self, pen): 82 self._spine.setPen(pen) 83 84 def setArrowVisible(self, visible): 85 self._arrow.setVisible(visible) 86 87 def paint(self, painter, option, widget): 88 pass 89 90 def boundingRect(self): 91 return QRectF() 92 93 def viewTransformChanged(self): 94 self.__updateLayout() 95 96 def __updateLayout(self): 97 T = self.sceneTransform() 98 if T is None: 99 T = QTransform() 100 101 # map the axis spine to scene coord. system. 102 viewbox_line = T.map(self._spine.line()) 103 angle = viewbox_line.angle() 104 assert not np.isnan(angle) 105 # note in Qt the y axis is inverted (90 degree angle 'points' down) 106 left_quad = 270 < angle <= 360 or -0.0 <= angle < 90 107 108 # position the text label along the viewbox_line 109 label_pos = self._spine.line().pointAt(0.90) 110 111 if left_quad: 112 # Anchor the text under the axis spine 113 anchor = (0.5, -0.1) 114 else: 115 # Anchor the text over the axis spine 116 anchor = (0.5, 1.1) 117 118 self._label.setPos(label_pos) 119 self._label.setAnchor(pg.Point(*anchor)) 120 self._label.setRotation(-angle if left_quad else 180 - angle) 121 122 self._arrow.setPos(self._spine.line().p2()) 123 self._arrow.setRotation(180 - angle) 124 125 126class HelpEventDelegate(QObject): 127 def __init__(self, delegate, parent=None): 128 super().__init__(parent) 129 self.delegate = delegate 130 131 def eventFilter(self, _, event): 132 if event.type() == QEvent.GraphicsSceneHelp: 133 return self.delegate(event) 134 else: 135 return False 136 137 138class MouseEventDelegate(HelpEventDelegate): 139 def __init__(self, delegate, delegate2, parent=None): 140 self.delegate2 = delegate2 141 super().__init__(delegate, parent=parent) 142 143 def eventFilter(self, obj, event): 144 if isinstance(event, QGraphicsSceneMouseEvent): 145 self.delegate2(event) 146 return super().eventFilter(obj, event) 147 148 149class InteractiveViewBox(pg.ViewBox): 150 def __init__(self, graph, enable_menu=False): 151 self.init_history() 152 pg.ViewBox.__init__(self, enableMenu=enable_menu) 153 self.graph = graph 154 self.setMouseMode(self.PanMode) 155 self.grabGesture(Qt.PinchGesture) 156 157 @staticmethod 158 def _dragtip_pos(): 159 return 10, 10 160 161 def setDragTooltip(self, tooltip): 162 scene = self.scene() 163 scene.addItem(tooltip) 164 tooltip.setPos(*self._dragtip_pos()) 165 tooltip.hide() 166 scene.drag_tooltip = tooltip 167 168 def updateScaleBox(self, p1, p2): 169 """ 170 Overload to use ViewBox.mapToView instead of mapRectFromParent 171 mapRectFromParent (from Qt) uses QTransform.invert() which has 172 floating-point issues and can't invert the matrix with large 173 coefficients. ViewBox.mapToView uses invertQTransform from pyqtgraph. 174 175 This code, except for first three lines, are copied from the overloaded 176 method. 177 """ 178 p1 = self.mapToView(p1) 179 p2 = self.mapToView(p2) 180 r = QRectF(p1, p2) 181 self.rbScaleBox.setPos(r.topLeft()) 182 tr = QTransform.fromScale(r.width(), r.height()) 183 self.rbScaleBox.setTransform(tr) 184 self.rbScaleBox.show() 185 186 def safe_update_scale_box(self, buttonDownPos, currentPos): 187 x, y = currentPos 188 if buttonDownPos[0] == x: 189 x += 1 190 if buttonDownPos[1] == y: 191 y += 1 192 self.updateScaleBox(buttonDownPos, pg.Point(x, y)) 193 194 def _updateDragtipShown(self, enabled): 195 scene = self.scene() 196 dragtip = scene.drag_tooltip 197 if enabled != dragtip.isVisible(): 198 dragtip.setVisible(enabled) 199 200 # noinspection PyPep8Naming,PyMethodOverriding 201 def mouseDragEvent(self, ev, axis=None): 202 def get_mapped_rect(): 203 p1, p2 = ev.buttonDownPos(ev.button()), ev.pos() 204 p1 = self.mapToView(p1) 205 p2 = self.mapToView(p2) 206 return QRectF(p1, p2) 207 208 def select(): 209 ev.accept() 210 if ev.button() == Qt.LeftButton: 211 self.safe_update_scale_box(ev.buttonDownPos(), ev.pos()) 212 if ev.isFinish(): 213 self._updateDragtipShown(False) 214 self.graph.unsuspend_jittering() 215 self.rbScaleBox.hide() 216 value_rect = get_mapped_rect() 217 self.graph.select_by_rectangle(value_rect) 218 else: 219 self._updateDragtipShown(True) 220 self.graph.suspend_jittering() 221 self.safe_update_scale_box(ev.buttonDownPos(), ev.pos()) 222 223 def zoom(): 224 # A fixed version of the code from the inherited mouseDragEvent 225 # Use mapToView instead of mapRectFromParent 226 ev.accept() 227 self.rbScaleBox.hide() 228 ax = get_mapped_rect() 229 self.showAxRect(ax) 230 self.axHistoryPointer += 1 231 self.axHistory = self.axHistory[:self.axHistoryPointer] + [ax] 232 233 if self.graph.state == SELECT and axis is None: 234 select() 235 elif self.graph.state == ZOOMING or self.graph.state == PANNING: 236 # Inherited mouseDragEvent doesn't work for large zooms because it 237 # uses mapRectFromParent. We don't want to copy the parts of the 238 # method that work, hence we only use our code under the following 239 # conditions. 240 if ev.button() & (Qt.LeftButton | Qt.MidButton) \ 241 and self.state['mouseMode'] == pg.ViewBox.RectMode \ 242 and ev.isFinish(): 243 zoom() 244 else: 245 super().mouseDragEvent(ev, axis=axis) 246 else: 247 ev.ignore() 248 249 def updateAutoRange(self): 250 # indirectly called by the autorange button on the graph 251 super().updateAutoRange() 252 self.tag_history() 253 254 def tag_history(self): 255 #add current view to history if it differs from the last view 256 if self.axHistory: 257 currentview = self.viewRect() 258 lastview = self.axHistory[self.axHistoryPointer] 259 inters = currentview & lastview 260 united = currentview.united(lastview) 261 if inters.width()*inters.height()/(united.width()*united.height()) > 0.95: 262 return 263 self.axHistoryPointer += 1 264 self.axHistory = self.axHistory[:self.axHistoryPointer] + \ 265 [self.viewRect()] 266 267 def init_history(self): 268 self.axHistory = [] 269 self.axHistoryPointer = -1 270 271 def autoRange(self, padding=None, items=None, item=None): 272 super().autoRange(padding=padding, items=items, item=item) 273 self.tag_history() 274 275 def suggestPadding(self, _): # no padding so that undo works correcty 276 return 0. 277 278 def scaleHistory(self, d): 279 self.tag_history() 280 super().scaleHistory(d) 281 282 def mouseClickEvent(self, ev): 283 if ev.button() == Qt.RightButton: # undo zoom 284 self.scaleHistory(-1) 285 elif ev.modifiers() == Qt.NoModifier: 286 ev.accept() 287 self.graph.unselect_all() 288 289 def sceneEvent(self, event): 290 if event.type() == QEvent.Gesture: 291 return self.gestureEvent(event) 292 return super().sceneEvent(event) 293 294 def gestureEvent(self, event): 295 gesture = event.gesture(Qt.PinchGesture) 296 if gesture.state() == Qt.GestureStarted: 297 event.accept(gesture) 298 elif gesture.changeFlags() & QPinchGesture.ScaleFactorChanged: 299 center = self.mapSceneToView(gesture.centerPoint()) 300 scale_prev = gesture.lastScaleFactor() 301 scale = gesture.scaleFactor() 302 if scale_prev != 0: 303 scale = scale / scale_prev 304 if scale > 0: 305 self.scaleBy((1 / scale, 1 / scale), center) 306 elif gesture.state() == Qt.GestureFinished: 307 self.tag_history() 308 309 return True 310 311 312class DraggableItemsViewBox(InteractiveViewBox): 313 """ 314 A viewbox with draggable items 315 316 Graph that uses it must provide two methods: 317 - `closest_draggable_item(pos)` returns an int representing the id of the 318 draggable item that is closest (and close enough) to `QPoint` pos, or 319 `None`; 320 - `show_indicator(item_id)` shows or updates an indicator for moving 321 the item with the given `item_id`. 322 323 Viewbox emits three signals: 324 - `started = Signal(item_id)` 325 - `moved = Signal(item_id, x, y)` 326 - `finished = Signal(item_id, x, y)` 327 """ 328 started = Signal(int) 329 moved = Signal(int, float, float) 330 finished = Signal(int, float, float) 331 332 def __init__(self, graph, enable_menu=False): 333 self.mouse_state = 0 334 self.item_id = None 335 super().__init__(graph, enable_menu) 336 337 def mousePressEvent(self, ev): 338 super().mousePressEvent(ev) 339 pos = self.childGroup.mapFromParent(ev.pos()) 340 if self.graph.closest_draggable_item(pos) is not None: 341 self.setCursor(Qt.ClosedHandCursor) 342 343 def mouseDragEvent(self, ev, axis=None): 344 pos = self.childGroup.mapFromParent(ev.pos()) 345 item_id = self.graph.closest_draggable_item(pos) 346 if ev.button() != Qt.LeftButton or (ev.start and item_id is None): 347 self.mouse_state = 2 348 if self.mouse_state == 2: 349 if ev.finish: 350 self.mouse_state = 0 351 super().mouseDragEvent(ev, axis) 352 return 353 354 ev.accept() 355 if ev.start: 356 self.setCursor(Qt.ClosedHandCursor) 357 self.mouse_state = 1 358 self.item_id = item_id 359 self.started.emit(self.item_id) 360 361 if self.mouse_state == 1: 362 if ev.finish: 363 self.mouse_state = 0 364 self.finished.emit(self.item_id, pos.x(), pos.y()) 365 if self.graph.closest_draggable_item(pos) is not None: 366 self.setCursor(Qt.OpenHandCursor) 367 else: 368 self.setCursor(Qt.ArrowCursor) 369 self.item_id = None 370 else: 371 self.moved.emit(self.item_id, pos.x(), pos.y()) 372 self.graph.show_indicator(self.item_id) 373 374 375def wrap_legend_items(items, max_width, hspacing, vspacing): 376 def line_width(line): 377 return sum(item.boundingRect().width() for item in line) \ 378 + hspacing * (len(line) - 1) 379 380 def create_line(line, yi, fixed_width=None): 381 x = 0 382 for item in line: 383 item.setPos(x, yi * vspacing) 384 paragraph.addToGroup(item) 385 if fixed_width: 386 x += fixed_width 387 else: 388 x += item.boundingRect().width() + hspacing 389 390 max_item = max(item.boundingRect().width() + hspacing for item in items) 391 in_line = int(max_width // max_item) 392 if line_width(items) < max_width: # single line 393 lines = [items] 394 fixed_width = None 395 elif in_line < 2: 396 lines = [[]] 397 for i, item in enumerate(items): # just a single column - free wrap 398 lines[-1].append(item) 399 if line_width(lines[-1]) > max_width and len(lines[-1]) > 1: 400 lines.append([lines[-1].pop()]) 401 fixed_width = None 402 else: # arrange into grid 403 lines = [items[i:i + in_line] 404 for i in range(0, len(items) + in_line - 1, in_line)] 405 fixed_width = max_item 406 407 paragraph = QGraphicsItemGroup() 408 for yi, line in enumerate(lines): 409 create_line(line, yi, fixed_width=fixed_width) 410 return paragraph 411 412 413class ElidedLabelsAxis(pg.AxisItem): 414 """ 415 Horizontal axis that elides long text labels 416 417 The class assumes that ticks with labels are distributed equally, and that 418 standard `QWidget.font()` is used for printing them. 419 """ 420 def generateDrawSpecs(self, p): 421 axis_spec, tick_specs, text_specs = super().generateDrawSpecs(p) 422 bounds = self.mapRectFromParent(self.geometry()) 423 max_width = 0.9 * bounds.width() / (len(text_specs) or 1) 424 elide = QFontMetrics(QWidget().font()).elidedText 425 text_specs = [(rect, flags, elide(text, Qt.ElideRight, max_width)) 426 for rect, flags, text in text_specs] 427 return axis_spec, tick_specs, text_specs 428 429 430class PaletteItemSample(ItemSample): 431 """A color strip to insert into legends for discretized continuous values""" 432 433 def __init__(self, palette, scale, label_formatter=None): 434 """ 435 :param palette: palette used for showing continuous values 436 :type palette: BinnedContinuousPalette 437 :param scale: an instance of DiscretizedScale that defines the 438 conversion of values into bins 439 :type scale: DiscretizedScale 440 """ 441 super().__init__(None) 442 self.palette = palette 443 self.scale = scale 444 if label_formatter is None: 445 label_formatter = "{{:.{}f}}".format(scale.decimals).format 446 cuts = [label_formatter(scale.offset + i * scale.width) 447 for i in range(scale.bins + 1)] 448 self.labels = [QStaticText("{} - {}".format(fr, to)) 449 for fr, to in zip(cuts, cuts[1:])] 450 self.font = self.font() 451 self.font.setPointSize(11) 452 453 @property 454 def bin_height(self): 455 return self.font.pointSize() + 4 456 457 @property 458 def text_width(self): 459 for label in self.labels: 460 label.prepare(font=self.font) 461 return max(label.size().width() for label in self.labels) 462 463 def set_font(self, font: QFont): 464 self.font = font 465 self.update() 466 467 def boundingRect(self): 468 return QRectF(0, 0, 469 25 + self.text_width + self.bin_height, 470 20 + self.scale.bins * self.bin_height) 471 472 def paint(self, p, *args): 473 p.setRenderHint(p.Antialiasing) 474 p.translate(5, 5) 475 p.setFont(self.font) 476 colors = self.palette.qcolors 477 h = self.bin_height 478 for i, color, label in zip(itertools.count(), colors, self.labels): 479 p.setPen(Qt.NoPen) 480 p.setBrush(QBrush(color)) 481 p.drawRect(0, i * h, h, h) 482 p.setPen(QPen(Qt.black)) 483 p.drawStaticText(h + 5, i * h + 1, label) 484 485 486class SymbolItemSample(ItemSample): 487 """Adjust position for symbols""" 488 def __init__(self, pen, brush, size, symbol): 489 super().__init__(None) 490 self.__pen = fn.mkPen(pen) 491 self.__brush = fn.mkBrush(brush) 492 self.__size = size 493 self.__symbol = symbol 494 495 def paint(self, p, *args): 496 p.translate(8, 12) 497 drawSymbol(p, self.__symbol, self.__size, self.__pen, self.__brush) 498 499 500class AxisItem(pg.AxisItem): 501 def __init__(self, orientation, rotate_ticks=False, **kwargs): 502 super().__init__(orientation, **kwargs) 503 self.style["rotateTicks"] = rotate_ticks 504 505 def setRotateTicks(self, rotate): 506 self.style["rotateTicks"] = rotate 507 self.picture = None # pylint: disable=attribute-defined-outside-init 508 self.prepareGeometryChange() 509 self.update() 510 511 def drawPicture(self, p, axisSpec, tickSpecs, textSpecs): 512 if self.orientation in ["bottom", "top"] and self.style["rotateTicks"]: 513 p.setRenderHint(p.Antialiasing, False) 514 p.setRenderHint(p.TextAntialiasing, True) 515 516 # draw long line along axis 517 pen, p1, p2 = axisSpec 518 p.setPen(pen) 519 p.drawLine(p1, p2) 520 p.translate(0.5, 0) # resolves some damn pixel ambiguity 521 522 # draw ticks 523 for pen, p1, p2 in tickSpecs: 524 p.setPen(pen) 525 p.drawLine(p1, p2) 526 527 # draw all text 528 if self.style['tickFont'] is not None: 529 p.setFont(self.style['tickFont']) 530 p.setPen(self.pen()) 531 532 offset = self.style["tickTextOffset"][0] 533 max_text_size = 0 534 for rect, flags, text in textSpecs: 535 p.save() 536 p.translate(rect.x() + rect.width() / 2 537 - rect.y() - rect.height() / 2, 538 rect.x() + rect.width() + offset) 539 p.rotate(-90) 540 p.drawText(rect, flags, text) 541 p.restore() 542 max_text_size = max(max_text_size, rect.width()) 543 self._updateMaxTextSize(max_text_size + offset) 544 else: 545 super().drawPicture(p, axisSpec, tickSpecs, textSpecs) 546