def __updateLayout(self): T = self.sceneTransform() if T is None: T = QTransform() # map the axis spine to scene coord. system. viewbox_line = T.map(self._spine.line()) angle = viewbox_line.angle() assert not np.isnan(angle) # note in Qt the y axis is inverted (90 degree angle 'points' down) left_quad = 270 < angle <= 360 or -0.0 <= angle < 90 # position the text label along the viewbox_line label_pos = self._spine.line().pointAt(0.90) if left_quad: # Anchor the text under the axis spine anchor = (0.5, -0.1) else: # Anchor the text over the axis spine anchor = (0.5, 1.1) self._label.setPos(label_pos) self._label.setAnchor(pg.Point(*anchor)) self._label.setRotation(-angle if left_quad else 180 - angle) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(180 - angle)
def __setZoomLevel(self, scale): self.__zoomLevel = max(30, min(scale, 300)) scale = round(self.__zoomLevel) self.__zoomOutAction.setEnabled(scale != 30) self.__zoomInAction.setEnabled(scale != 300) if self.__effectiveZoomLevel != scale: self.__effectiveZoomLevel = scale transform = QTransform() transform.scale(scale / 100, scale / 100) self.setTransform(transform) self.zoomLevelChanged.emit(scale)
def __init__(self, *args, **kwargs): QDockWidget.__init__(self, *args, **kwargs) self.__expandedWidget = None self.__collapsedWidget = None self.__expanded = True self.__trueMinimumWidth = -1 self.setFeatures(QDockWidget.DockWidgetClosable | \ QDockWidget.DockWidgetMovable) self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.featuresChanged.connect(self.__onFeaturesChanged) self.dockLocationChanged.connect(self.__onDockLocationChanged) # Use the toolbar horizontal extension button icon as the default # for the expand/collapse button icon = self.style().standardIcon( QStyle.SP_ToolBarHorizontalExtensionButton) # Mirror the icon transform = QTransform() transform = transform.scale(-1.0, 1.0) icon_rev = QIcon() for s in (8, 12, 14, 16, 18, 24, 32, 48, 64): pm = icon.pixmap(s, s) icon_rev.addPixmap(pm.transformed(transform)) self.__iconRight = QIcon(icon) self.__iconLeft = QIcon(icon_rev) close = self.findChild(QAbstractButton, name="qt_dockwidget_closebutton") close.installEventFilter(self) self.__closeButton = close self.__stack = AnimatedStackedWidget() self.__stack.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__stack.transitionStarted.connect(self.__onTransitionStarted) self.__stack.transitionFinished.connect(self.__onTransitionFinished) QDockWidget.setWidget(self, self.__stack) self.__closeButton.setIcon(self.__iconLeft)
def ellipse_path(center, a, b, rotation=0): if not isinstance(center, QPointF): center = QPointF(*center) brect = QRectF(-a, -b, 2 * a, 2 * b) path = QPainterPath() path.addEllipse(brect) if rotation != 0: transform = QTransform().rotate(rotation) path = transform.map(path) path.translate(center) return path
def _define_symbols(): """ Add symbol ? to ScatterPlotItemSymbols, reflect the triangle to point upwards """ symbols = pyqtgraph.graphicsItems.ScatterPlotItem.Symbols path = QPainterPath() path.addEllipse(QRectF(-0.35, -0.35, 0.7, 0.7)) path.moveTo(-0.5, 0.5) path.lineTo(0.5, -0.5) path.moveTo(-0.5, -0.5) path.lineTo(0.5, 0.5) symbols["?"] = path tr = QTransform() tr.rotate(180) symbols['t'] = tr.map(symbols['t'])
def __updateText(self): self.prepareGeometryChange() self.__boundingRect = None if self.__sourceName or self.__sinkName: if self.__sourceName != self.__sinkName: text = "{0} \u2192 {1}".format(self.__sourceName, self.__sinkName) else: # If the names are the same show only one. # Is this right? If the sink has two input channels of the # same type having the name on the link help elucidate # the scheme. text = self.__sourceName else: text = "" self.linkTextItem.setPlainText(text) path = self.curveItem.curvePath() if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) brect = self.linkTextItem.boundingRect() transform = QTransform() transform.translate(center.x(), center.y()) transform.rotate(-angle) # Center and move above the curve path. transform.translate(-brect.width() / 2, -brect.height()) self.linkTextItem.setTransform(transform)
def __setZoomLevel(self, scale, anchor=None): # type: (float, Optional[QPointF]) -> None self.__zoomLevel = max(30, min(scale, 300)) scale = round(self.__zoomLevel) self.__zoomOutAction.setEnabled(scale != 30) self.__zoomInAction.setEnabled(scale != 300) if self.__effectiveZoomLevel != scale: self.__effectiveZoomLevel = scale transform = QTransform() transform.scale(scale / 100, scale / 100) if anchor is not None: anchor = self.mapFromScene(anchor) self.setTransform(transform) if anchor is not None: center = self.viewport().rect().center() diff = self.mapToScene(center) - self.mapToScene(anchor) self.centerOn(anchor + diff) self.zoomLevelChanged.emit(scale)
def pixmapTransform(self) -> QTransform: if self.__pixmap.isNull(): return QTransform() pxsize = QSizeF(self.__pixmap.size()) crect = self.contentsRect() transform = QTransform() transform = transform.translate(crect.left(), crect.top()) if self.__scaleContents: csize = scaled(pxsize, crect.size(), self.__aspectMode) else: csize = pxsize xscale = csize.width() / pxsize.width() yscale = csize.height() / pxsize.height() return transform.scale(xscale, yscale)
def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.__expandedWidget = None # type: Optional[QWidget] self.__collapsedWidget = None # type: Optional[QWidget] self.__expanded = True self.__trueMinimumWidth = -1 self.setFeatures(QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetMovable) self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.dockLocationChanged.connect(self.__onDockLocationChanged) # Use the toolbar horizontal extension button icon as the default # for the expand/collapse button icon = self.style().standardIcon( QStyle.SP_ToolBarHorizontalExtensionButton) # Mirror the icon transform = QTransform() transform = transform.scale(-1.0, 1.0) icon_rev = QIcon() for s in (8, 12, 14, 16, 18, 24, 32, 48, 64): pm = icon.pixmap(s, s) icon_rev.addPixmap(pm.transformed(transform)) self.__iconRight = QIcon(icon) self.__iconLeft = QIcon(icon_rev) # Find the close button an install an event filter or close event close = self.findChild(QAbstractButton, name="qt_dockwidget_closebutton") assert close is not None close.installEventFilter(self) self.__closeButton = close self.__stack = AnimatedStackedWidget() self.__stack.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) super().setWidget(self.__stack) self.__closeButton.setIcon(self.__iconLeft)
def __init__(self, *args, **kwargs): QDockWidget.__init__(self, *args, **kwargs) self.__expandedWidget = None self.__collapsedWidget = None self.__expanded = True self.__trueMinimumWidth = -1 self.setFeatures(QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetMovable) self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.featuresChanged.connect(self.__onFeaturesChanged) self.dockLocationChanged.connect(self.__onDockLocationChanged) # Use the toolbar horizontal extension button icon as the default # for the expand/collapse button pm = self.style().standardPixmap(QStyle.SP_ToolBarHorizontalExtensionButton) # Rotate the icon transform = QTransform() transform.rotate(180) pm_rev = pm.transformed(transform) self.__iconRight = QIcon(pm) self.__iconLeft = QIcon(pm_rev) close = self.findChild(QAbstractButton, name="qt_dockwidget_closebutton") close.installEventFilter(self) self.__closeButton = close self.__stack = AnimatedStackedWidget() self.__stack.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__stack.transitionStarted.connect(self.__onTransitionStarted) self.__stack.transitionFinished.connect(self.__onTransitionFinished) QDockWidget.setWidget(self, self.__stack) self.__closeButton.setIcon(self.__iconLeft)
def _updateLayout(self): rect = self.geometry() n = len(self._items) if not n: return regions = venn_diagram(n, shape=self.shapeType) # The y axis in Qt points downward transform = QTransform().scale(1, -1) regions = list(map(transform.map, regions)) union_brect = reduce(QRectF.united, (path.boundingRect() for path in regions)) scalex = rect.width() / union_brect.width() scaley = rect.height() / union_brect.height() scale = min(scalex, scaley) transform = QTransform().scale(scale, scale) regions = [transform.map(path) for path in regions] center = rect.width() / 2, rect.height() / 2 for item, path in zip(self.items(), regions): item.setPath(path) item.setPos(*center) intersections = venn_intersections(regions) assert len(intersections) == 2 ** n assert len(self.vennareas()) == 2 ** n anchors = [(0, 0)] + subset_anchors(self._items) anchor_transform = QTransform().scale(rect.width(), -rect.height()) for i, area in enumerate(self.vennareas()): area.setPath(intersections[setkey(i, n)]) area.setPos(*center) x, y = anchors[i] anchor = anchor_transform.map(QPointF(x, y)) area.setTextAnchor(anchor) area.setZValue(30) self._updateTextAnchors()
def __updateText(self): self.prepareGeometryChange() self.__boundingRect = None if self.__sourceName or self.__sinkName: if self.__sourceName != self.__sinkName: text = ("<nobr>{0}</nobr> \u2192 <nobr>{1}</nobr>" .format(escape(self.__sourceName), escape(self.__sinkName))) else: # If the names are the same show only one. # Is this right? If the sink has two input channels of the # same type having the name on the link help elucidate # the scheme. text = escape(self.__sourceName) else: text = "" self.linkTextItem.setHtml('<div align="center">{0}</div>' .format(text)) path = self.curveItem.curvePath() # Constrain the text width if it is too long to fit on a single line # between the two ends if not path.isEmpty(): # Use the distance between the start/end points as a measure of # available space diff = path.pointAtPercent(0.0) - path.pointAtPercent(1.0) available_width = math.sqrt(diff.x() ** 2 + diff.y() ** 2) # Get the ideal text width if it was unconstrained doc = self.linkTextItem.document().clone(self) doc.setTextWidth(-1) idealwidth = doc.idealWidth() doc.deleteLater() # Constrain the text width but not below a certain min width minwidth = 100 textwidth = max(minwidth, min(available_width, idealwidth)) self.linkTextItem.setTextWidth(textwidth) else: # Reset the fixed width self.linkTextItem.setTextWidth(-1) if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) brect = self.linkTextItem.boundingRect() transform = QTransform() transform.translate(center.x(), center.y()) transform.rotate(-angle) # Center and move above the curve path. transform.translate(-brect.width() / 2, -brect.height()) self.linkTextItem.setTransform(transform)
def _scaleToFit(self): widget = self.__centralWidget if widget is None or not self.__fitInView: return vpsize = self.__viewportContentSize() size = widget.size() if not size.isEmpty(): sc = scaled(size, vpsize, self.__aspectMode) sx = sc.width() / size.width() sy = sc.height() / size.height() self.setTransform(QTransform().scale(sx, sy))
def paint(self, painter, option, widget=None): t = painter.transform() rect = t.mapRect(self.rect()) painter.save() painter.setTransform(QTransform()) pwidth = self.pen().widthF() painter.setPen(self.pen()) painter.drawRect(rect.adjusted(pwidth, -pwidth, -pwidth, pwidth)) painter.restore()
def __init__(self, id, title='', title_above=False, title_location=AxisMiddle, line=None, arrows=0, plot=None, bounds=None): QGraphicsItem.__init__(self) self.setFlag(QGraphicsItem.ItemHasNoContents) self.setZValue(AxisZValue) self.id = id self.title = title self.title_location = title_location self.data_line = line self.plot = plot self.graph_line = None self.size = None self.scale = None self.tick_length = (10, 5, 0) self.arrows = arrows self.title_above = title_above self.line_item = QGraphicsLineItem(self) self.title_item = QGraphicsTextItem(self) self.end_arrow_item = None self.start_arrow_item = None self.show_title = False self.scale = None path = QPainterPath() path.setFillRule(Qt.WindingFill) path.moveTo(0, 3.09) path.lineTo(0, -3.09) path.lineTo(9.51, 0) path.closeSubpath() self.arrow_path = path self.label_items = [] self.label_bg_items = [] self.tick_items = [] self._ticks = [] self.zoom_transform = QTransform() self.labels = None self.values = None self._bounds = bounds self.auto_range = None self.auto_scale = True self.zoomable = False self.update_callback = None self.max_text_width = 50 self.text_margin = 5 self.always_horizontal_text = False
def __init__(self, stamp, position, scene, adjust_position=True, matrix=QTransform()): super(StampItem, self).__init__() self.setFlags(QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemIsMovable) self.setStamp(stamp) self.setPos(position) self.setTransform(matrix) scene.clearSelection() scene.addItem(self) self.setSelected(True) if adjust_position: bb = self.boundingRect() self.moveBy(-(bb.width()//2 + 3), -(bb.height()//2 + 3))
def _rescale(self): if self._root is None: return scale = self._height_scale_factor() base = scale * self._root.value.height crect = self.contentsRect() leaf_count = len(list(leaves(self._root))) if self.orientation in [Left, Right]: drect = QSizeF(base, leaf_count) else: drect = QSizeF(leaf_count, base) eps = np.finfo(np.float64).eps if abs(drect.width()) < eps: sx = 1.0 else: sx = crect.width() / drect.width() if abs(drect.height()) < eps: sy = 1.0 else: sy = crect.height() / drect.height() transform = QTransform().scale(sx, sy) self._transform = transform self._itemgroup.setPos(crect.topLeft()) self._itemgroup.setGeometry(crect) for node_geom in postorder(self._layout): node, _ = node_geom.value item = self._items[node] item.setGeometryData(transform.map(item.sourcePath), transform.map(item.sourceAreaShape)) self._selection_items = None self._update_selection_items()
def _updateLayout(self): rect = self.geometry() n = len(self._items) if not n: return regions = venn_diagram(n) # The y axis in Qt points downward transform = QTransform().scale(1, -1) regions = list(map(transform.map, regions)) union_brect = reduce(QRectF.united, (path.boundingRect() for path in regions)) scalex = rect.width() / union_brect.width() scaley = rect.height() / union_brect.height() scale = min(scalex, scaley) transform = QTransform().scale(scale, scale) regions = [transform.map(path) for path in regions] center = rect.width() / 2, rect.height() / 2 for item, path in zip(self.items(), regions): item.setPath(path) item.setPos(*center) intersections = venn_intersections(regions) assert len(intersections) == 2 ** n assert len(self.vennareas()) == 2 ** n anchors = [(0, 0)] + subset_anchors(self._items) anchor_transform = QTransform().scale(rect.width(), -rect.height()) for i, area in enumerate(self.vennareas()): area.setPath(intersections[setkey(i, n)]) area.setPos(*center) x, y = anchors[i] anchor = anchor_transform.map(QPointF(x, y)) area.setTextAnchor(anchor) area.setZValue(30) self._updateTextAnchors()
def updateScaleBox(self, p1, p2): """ Overload to use ViewBox.mapToView instead of mapRectFromParent mapRectFromParent (from Qt) uses QTransform.invert() which has floating-point issues and can't invert the matrix with large coefficients. ViewBox.mapToView uses invertQTransform from pyqtgraph. This code, except for first three lines, are copied from the overloaded method. """ p1 = self.mapToView(p1) p2 = self.mapToView(p2) r = QRectF(p1, p2) self.rbScaleBox.setPos(r.topLeft()) tr = QTransform.fromScale(r.width(), r.height()) self.rbScaleBox.setTransform(tr) self.rbScaleBox.show()
def __static_text_elided_cache( self, text: str, font: QFont, fontMetrics: QFontMetrics, elideMode: Qt.TextElideMode, width: int ) -> QStaticText: """ Return a `QStaticText` instance for depicting the text with the `font` """ try: return self.__static_text_lru_cache[text, font, elideMode, width] except KeyError: text = fontMetrics.elidedText(text, elideMode, width) st = QStaticText(text) st.prepare(QTransform(), font) # take a copy of the font for cache key key = text, QFont(font), elideMode, width self.__static_text_lru_cache[key] = st return st
def setHistogram(self, values=None, bins=None, use_kde=False, histogram=None): """ Set background histogram (or density estimation, violin plot) The histogram of bins is calculated from values, optionally as a Gaussian KDE. If histogram is provided, its values are used directly and other parameters are ignored. """ if (values is None or not len(values)) and histogram is None: self.setPixmap(None) return if histogram is not None: self._histogram = hist = histogram else: if bins is None: bins = min(100, max(10, len(values) // 20)) if use_kde: hist = gaussian_kde( values, None if isinstance(use_kde, bool) else use_kde)( np.linspace(np.min(values), np.max(values), bins)) else: hist = np.histogram(values, bins)[0] self._histogram = hist = hist / hist.max() HEIGHT = self.rect().height() / 2 OFFSET = HEIGHT * .3 pixmap = QPixmap(QSize( len(hist), 2 * (HEIGHT + OFFSET))) # +1 avoids right/bottom frame border shadow pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setPen(QPen(Qt.darkGray)) for x, value in enumerate(hist): painter.drawLine(x, HEIGHT * (1 - value) + OFFSET, x, HEIGHT * (1 + value) + OFFSET) if self.orientation() != Qt.Horizontal: pixmap = pixmap.transformed(QTransform().rotate(-90)) self.setPixmap(pixmap)
def rescale(self): if self.legend: leg_height = self.legend.boundingRect().height() leg_extra = 1.5 else: leg_height = 0 leg_extra = 1 vw, vh = self.view.width(), self.view.height() - leg_height scale = min(vw / (self.size_x + 1), vh / ((self.size_y + leg_extra) * self._grid_factors[1])) self.view.setTransform(QTransform.fromScale(scale, scale)) if self.hexagonal: self.view.setSceneRect(0, -1, self.size_x - 1, (self.size_y + leg_extra) * sqrt3_2 + leg_height / scale) else: self.view.setSceneRect(-0.25, -0.25, self.size_x - 0.5, self.size_y - 0.5 + leg_height / scale)
def setZoomFactor(self, factor: float) -> None: """ Set the zoom level `factor` Parameters ---------- factor: Zoom level where 100 is default 50 is half the size and 200 is twice the size """ if self.__zoomFactor != factor or self.__fitInView: self.__fitInView = False self._actions.fit.setChecked(False) self.__zoomFactor = factor self.setTransform( QTransform.fromScale(*(self.__zoomFactor / 100, ) * 2)) self._actions.zoomout.setEnabled(factor >= 20) self._actions.zoomin.setEnabled(factor <= 300) self.zoomFactorChanged.emit(factor) if self.__widgetResizable: self._resizeToFit()
def _setup_scene(self): self._clear_plot() self.matrix_item = DistanceMapItem(self._sorted_matrix) # Scale the y axis to compensate for pg.ViewBox's y axis invert self.matrix_item.setTransform(QTransform.fromScale(1, -1), ) self.viewbox.addItem(self.matrix_item) # Set fixed view box range. h, w = self._sorted_matrix.shape self.viewbox.setRange(QRectF(0, -h, w, h), padding=0) self.matrix_item.selectionChanged.connect(self._invalidate_selection) if self.sorting == OWDistanceMap.NoOrdering: tree = None elif self.sorting == OWDistanceMap.Clustering: tree = self._cluster_tree() elif self.sorting == OWDistanceMap.OrderedClustering: tree = self._ordered_cluster_tree() self._set_displayed_dendrogram(tree) self._update_color()
def __set_zoom(self, scale): self.__scale = min(15, max(scale, 3)) transform = QTransform() transform.scale(self.__scale / 10, self.__scale / 10) self.setTransform(transform)
def __updateText(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.__sourceName or self.__sinkName: if self.__sourceName != self.__sinkName: text = ("<nobr>{0}</nobr> \u2192 <nobr>{1}</nobr>".format( escape(self.__sourceName), escape(self.__sinkName))) else: # If the names are the same show only one. # Is this right? If the sink has two input channels of the # same type having the name on the link help elucidate # the scheme. text = escape(self.__sourceName) else: text = "" self.linkTextItem.setHtml( '<div align="center" style="font-size: small" >{0}</div>'.format( text)) path = self.curveItem.curvePath() # Constrain the text width if it is too long to fit on a single line # between the two ends if not path.isEmpty(): # Use the distance between the start/end points as a measure of # available space diff = path.pointAtPercent(0.0) - path.pointAtPercent(1.0) available_width = math.sqrt(diff.x()**2 + diff.y()**2) # Get the ideal text width if it was unconstrained doc = self.linkTextItem.document().clone(self) doc.setTextWidth(-1) idealwidth = doc.idealWidth() doc.deleteLater() # Constrain the text width but not below a certain min width minwidth = 100 textwidth = max(minwidth, min(available_width, idealwidth)) self.linkTextItem.setTextWidth(textwidth) else: # Reset the fixed width self.linkTextItem.setTextWidth(-1) if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) brect = self.linkTextItem.boundingRect() transform = QTransform() transform.translate(center.x(), center.y()) # Rotate text to be on top of link if 90 <= angle < 270: transform.rotate(180 - angle) else: transform.rotate(-angle) # Center and move above the curve path. transform.translate(-brect.width() / 2, -brect.height()) self.linkTextItem.setTransform(transform)
def toggle_zoom_slider(self): k = 0.0028 * (self.zoom ** 2) + 0.2583 * self.zoom + 1.1389 self.scene_view.setTransform(QTransform().scale(k / 2, k / 2)) self.scene.update()
def updateTransform(self): if self.master.autoResize: self.fitInView(self.scene().sceneRect().adjusted(-1, -1, 1, 1), Qt.KeepAspectRatio) else: self.setTransform(QTransform())
class DendrogramWidget(QGraphicsWidget): """A Graphics Widget displaying a dendrogram.""" class ClusterGraphicsItem(QGraphicsPathItem): #: The untransformed source path in 'dendrogram' logical coordinate #: system sourcePath = QPainterPath() # type: QPainterPath sourceAreaShape = QPainterPath() # type: QPainterPath __shape = None # type: Optional[QPainterPath] __boundingRect = None # type: Optional[QRectF] #: An extended path describing the full mouse hit area #: (extends all the way to the base of the dendrogram) __mouseAreaShape = QPainterPath() # type: QPainterPath def setGeometryData(self, path, hitArea): # type: (QPainterPath, QPainterPath) -> None """ Set the geometry (path) and the mouse hit area (hitArea) for this item. """ super().setPath(path) self.prepareGeometryChange() self.__boundingRect = self.__shape = None self.__mouseAreaShape = hitArea def shape(self): # type: () -> QPainterPath if self.__shape is None: path = super().shape() # type: QPainterPath self.__shape = path.united(self.__mouseAreaShape) return self.__shape def boundingRect(self): # type: () -> QRectF if self.__boundingRect is None: sh = self.shape() pw = self.pen().widthF() / 2.0 self.__boundingRect = sh.boundingRect().adjusted( -pw, -pw, pw, pw) return self.__boundingRect class _SelectionItem(QGraphicsItemGroup): def __init__(self, parent, path, unscaled_path, label=""): super().__init__(parent) self.path = QGraphicsPathItem(path, self) self.path.setPen(make_pen(width=1, cosmetic=True)) self.addToGroup(self.path) self.label = QGraphicsSimpleTextItem(label) self._update_label_pos() self.addToGroup(self.label) self.unscaled_path = unscaled_path def set_path(self, path): self.path.setPath(path) self._update_label_pos() def set_label(self, label): self.label.setText(label) self._update_label_pos() def set_color(self, color): self.path.setBrush(QColor(color)) def _update_label_pos(self): path = self.path.path() elements = (path.elementAt(i) for i in range(path.elementCount())) points = ((p.x, p.y) for p in elements) p1, p2, *rest = sorted(points) x, y = p1[0], (p1[1] + p2[1]) / 2 brect = self.label.boundingRect() # leaf nodes' paths are 4 pixels higher; leafs are `len(rest) == 3` self.label.setPos(x - brect.width() - 4, y - brect.height() + 4 * (len(rest) == 3)) #: Orientation Left, Top, Right, Bottom = 1, 2, 3, 4 #: Selection flags NoSelection, SingleSelection, ExtendedSelection = 0, 1, 2 #: Emitted when a user clicks on the cluster item. itemClicked = Signal(ClusterGraphicsItem) #: Signal emitted when the selection changes. selectionChanged = Signal() #: Signal emitted when the selection was changed by the user. selectionEdited = Signal() def __init__(self, parent=None, root=None, orientation=Left, hoverHighlightEnabled=True, selectionMode=ExtendedSelection, **kwargs): super().__init__(None, **kwargs) # Filter all events from children (`ClusterGraphicsItem`s) self.setFiltersChildEvents(True) self.orientation = orientation self._root = None #: A tree with dendrogram geometry self._layout = None self._highlighted_item = None #: a list of selected items self._selection = OrderedDict() #: a {node: item} mapping self._items = { } # type: Dict[Tree, DendrogramWidget.ClusterGraphicsItem] #: container for all cluster items. self._itemgroup = QGraphicsWidget(self) self._itemgroup.setGeometry(self.contentsRect()) #: Transform mapping from 'dendrogram' to widget local coordinate #: system self._transform = QTransform() self._cluster_parent = {} self.__hoverHighlightEnabled = hoverHighlightEnabled self.__selectionMode = selectionMode self.setContentsMargins(0, 0, 0, 0) self.setRoot(root) if parent is not None: self.setParentItem(parent) def setSelectionMode(self, mode): """ Set the selection mode. """ assert mode in [ DendrogramWidget.NoSelection, DendrogramWidget.SingleSelection, DendrogramWidget.ExtendedSelection ] if self.__selectionMode != mode: self.__selectionMode = mode if self.__selectionMode == DendrogramWidget.NoSelection and \ self._selection: self.setSelectedClusters([]) elif self.__selectionMode == DendrogramWidget.SingleSelection and \ len(self._selection) > 1: self.setSelectedClusters([self.selected_nodes()[-1]]) def selectionMode(self): """ Return the current selection mode. """ return self.__selectionMode def setHoverHighlightEnabled(self, enabled): if self.__hoverHighlightEnabled != bool(enabled): self.__hoverHighlightEnabled = bool(enabled) if self._highlighted_item is not None: self._set_hover_item(None) def isHoverHighlightEnabled(self): return self.__hoverHighlightEnabled def clear(self): """ Clear the widget. """ scene = self.scene() if scene is not None: scene.removeItem(self._itemgroup) else: self._itemgroup.setParentItem(None) self._itemgroup = QGraphicsWidget(self) self._itemgroup.setGeometry(self.contentsRect()) self._items.clear() for item in self._selection.values(): if scene is not None: scene.removeItem(item) else: item.setParentItem(None) self._root = None self._items = {} self._selection = OrderedDict() self._highlighted_item = None self._cluster_parent = {} self.updateGeometry() def setRoot(self, root): # type: (Tree) -> None """ Set the root cluster tree node for display. Parameters ---------- root : Tree The tree root node. """ self.clear() self._root = root if root is not None: foreground = self.palette().color(QPalette.Foreground) pen = make_pen(foreground, width=1, cosmetic=True, join_style=Qt.MiterJoin) for node in postorder(root): item = DendrogramWidget.ClusterGraphicsItem(self._itemgroup) item.setAcceptHoverEvents(True) item.setPen(pen) item.node = node for branch in node.branches: assert branch in self._items self._cluster_parent[branch] = node self._items[node] = item self._relayout() self._rescale() self.updateGeometry() set_root = setRoot def root(self): # type: () -> Tree """ Return the cluster tree root node. Returns ------- root : Tree """ return self._root def item(self, node): # type: (Tree) -> DendrogramWidget.ClusterGraphicsItem """ Return the ClusterGraphicsItem instance representing the cluster `node`. """ return self._items.get(node) def heightAt(self, point): # type: (QPointF) -> float """ Return the cluster height at the point in widget local coordinates. """ if not self._root: return 0 tinv, ok = self._transform.inverted() if not ok: return 0 tpoint = tinv.map(point) if self.orientation in [self.Left, self.Right]: height = tpoint.x() else: height = tpoint.y() # Undo geometry prescaling base = self._root.value.height scale = self._height_scale_factor() # Use better better precision then double provides. Fr = fractions.Fraction if scale > 0: height = Fr(height) / Fr(scale) else: height = 0 if self.orientation in [self.Left, self.Bottom]: height = Fr(base) - Fr(height) return float(height) height_at = heightAt def posAtHeight(self, height): # type: (float) -> float """ Return a point in local coordinates for `height` (in cluster """ if not self._root: return QPointF() scale = self._height_scale_factor() base = self._root.value.height height = scale * height if self.orientation in [self.Left, self.Bottom]: height = scale * base - height if self.orientation in [self.Left, self.Right]: p = QPointF(height, 0) else: p = QPointF(0, height) return self._transform.map(p) pos_at_height = posAtHeight def _set_hover_item(self, item): """Set the currently highlighted item.""" if self._highlighted_item is item: return def set_pen(item, pen): def branches(item): return [self._items[ch] for ch in item.node.branches] for it in postorder(item, branches): it.setPen(pen) if self._highlighted_item: # Restore the previous item highlight = self.palette().color(QPalette.Foreground) set_pen(self._highlighted_item, make_pen(highlight, width=1, cosmetic=True)) self._highlighted_item = item if item: hpen = make_pen(self.palette().color(QPalette.Highlight), width=2, cosmetic=True) set_pen(item, hpen) def leafItems(self): """Iterate over the dendrogram leaf items (:class:`QGraphicsItem`). """ if self._root: return (self._items[leaf] for leaf in leaves(self._root)) else: return iter(()) leaf_items = leafItems def leafAnchors(self): """Iterate over the dendrogram leaf anchor points (:class:`QPointF`). The points are in the widget local coordinates. """ for item in self.leafItems(): anchor = QPointF(item.element.anchor) yield self.mapFromItem(item, anchor) leaf_anchors = leafAnchors def selectedNodes(self): """ Return the selected cluster nodes. """ return [item.node for item in self._selection] selected_nodes = selectedNodes def setSelectedItems(self, items: List[ClusterGraphicsItem]): """Set the item selection.""" to_remove = set(self._selection) - set(items) to_add = set(items) - set(self._selection) for sel in to_remove: self._remove_selection(sel) for sel in to_add: self._add_selection(sel) if to_add or to_remove: self._re_enumerate_selections() self.selectionChanged.emit() set_selected_items = setSelectedItems def setSelectedClusters(self, clusters: List[Tree]) -> None: """Set the selected clusters. """ self.setSelectedItems(list(map(self.item, clusters))) set_selected_clusters = setSelectedClusters def isItemSelected(self, item: ClusterGraphicsItem) -> bool: """Is `item` selected (is a root of a selection).""" return item in self._selection def isItemIncludedInSelection(self, item: ClusterGraphicsItem) -> bool: """Is item included in any selection.""" return self._selected_super_item(item) is not None is_included = isItemIncludedInSelection def setItemSelected(self, item, state): # type: (ClusterGraphicsItem, bool) -> None """Set the `item`s selection state to `state`.""" if state is False and item not in self._selection or \ state is True and item in self._selection: return # State unchanged if item in self._selection: if state is False: self._remove_selection(item) self._re_enumerate_selections() self.selectionChanged.emit() else: # If item is already inside another selected item, # remove that selection super_selection = self._selected_super_item(item) if super_selection: self._remove_selection(super_selection) # Remove selections this selection will override. sub_selections = self._selected_sub_items(item) for sub in sub_selections: self._remove_selection(sub) if state: self._add_selection(item) elif item in self._selection: self._remove_selection(item) self._re_enumerate_selections() self.selectionChanged.emit() select_item = setItemSelected @staticmethod def _create_path(item, path): ppath = QPainterPath() if item.node.is_leaf: ppath.addRect(path.boundingRect().adjusted(-8, -4, 0, 4)) else: ppath.addPolygon(path) ppath = path_outline(ppath, width=-8) return ppath @staticmethod def _create_label(i): return f"C{i + 1}" def _add_selection(self, item): """Add selection rooted at item """ outline = self._selection_poly(item) path = self._transform.map(outline) ppath = self._create_path(item, path) label = self._create_label(len(self._selection)) selection_item = self._SelectionItem(self, ppath, outline, label) selection_item.label.setBrush(self.palette().color(QPalette.Link)) selection_item.setPos(self.contentsRect().topLeft()) self._selection[item] = selection_item def _remove_selection(self, item): """Remove selection rooted at item.""" selection_item = self._selection[item] selection_item.hide() selection_item.setParentItem(None) if self.scene(): self.scene().removeItem(selection_item) del self._selection[item] def _selected_sub_items(self, item): """Return all selected subclusters under item.""" def branches(item): return [self._items[ch] for ch in item.node.branches] res = [] for item in list(preorder(item, branches))[1:]: if item in self._selection: res.append(item) return res def _selected_super_item(self, item): """Return the selected super item if it exists.""" def branches(item): return [self._items[ch] for ch in item.node.branches] for selected_item in self._selection: if item in set(preorder(selected_item, branches)): return selected_item return None def _re_enumerate_selections(self): """Re enumerate the selection items and update the colors.""" # Order the clusters items = sorted(self._selection.items(), key=lambda item: item[0].node.value.first) palette = colorpalettes.LimitedDiscretePalette(len(items)) for i, (item, selection_item) in enumerate(items): # delete and then reinsert to update the ordering del self._selection[item] self._selection[item] = selection_item selection_item.set_label(self._create_label(i)) color = palette[i] color.setAlpha(150) selection_item.set_color(color) def _selection_poly(self, item): # type: (Tree) -> QPolygonF """ Return an selection geometry covering item and all its children. """ def left(item): return [self._items[ch] for ch in item.node.branches[:1]] def right(item): return [self._items[ch] for ch in item.node.branches[-1:]] itemsleft = list(preorder(item, left))[::-1] itemsright = list(preorder(item, right)) # itemsleft + itemsright walks from the leftmost leaf up to the root # and down to the rightmost leaf assert itemsleft[0].node.is_leaf assert itemsright[-1].node.is_leaf if item.node.is_leaf: # a single anchor point vert = [itemsleft[0].element.anchor] else: vert = [] for it in itemsleft[1:]: vert.extend([ it.element.path[0], it.element.path[1], it.element.anchor ]) for it in itemsright[:-1]: vert.extend([ it.element.anchor, it.element.path[-2], it.element.path[-1] ]) # close the polygon vert.append(vert[0]) def isclose(a, b, rel_tol=1e-6): return abs(a - b) < rel_tol * max(abs(a), abs(b)) def isclose_p(p1, p2, rel_tol=1e-6): return isclose(p1.x, p2.x, rel_tol) and \ isclose(p1.y, p2.y, rel_tol) # merge consecutive vertices that are (too) close acc = [vert[0]] for v in vert[1:]: if not isclose_p(v, acc[-1]): acc.append(v) vert = acc return QPolygonF([QPointF(*p) for p in vert]) def _update_selection_items(self): """Update the shapes of selection items after a scale change. """ transform = self._transform for item, selection in self._selection.items(): path = transform.map(selection.unscaled_path) ppath = self._create_path(item, path) selection.set_path(ppath) def _height_scale_factor(self): # Internal dendrogram height scale factor. The dendrogram geometry is # scaled by this factor to better condition the geometry if self._root is None: return 1 base = self._root.value.height # implicitly scale the geometry to 0..1 scale or flush to 0 for fuzz if base >= np.finfo(base).eps: return 1 / base else: return 0 def _relayout(self): if self._root is None: return scale = self._height_scale_factor() base = scale * self._root.value.height self._layout = dendrogram_path(self._root, self.orientation, scaleh=scale) for node_geom in postorder(self._layout): node, geom = node_geom.value item = self._items[node] item.element = geom # the untransformed source path item.sourcePath = path_toQtPath(geom) r = item.sourcePath.boundingRect() if self.orientation == Left: r.setRight(base) elif self.orientation == Right: r.setLeft(0) elif self.orientation == Top: r.setBottom(base) else: r.setTop(0) hitarea = QPainterPath() hitarea.addRect(r) item.sourceAreaShape = hitarea item.setGeometryData(item.sourcePath, item.sourceAreaShape) item.setZValue(-node.value.height) def _rescale(self): if self._root is None: return scale = self._height_scale_factor() base = scale * self._root.value.height crect = self.contentsRect() leaf_count = len(list(leaves(self._root))) if self.orientation in [Left, Right]: drect = QSizeF(base, leaf_count) else: drect = QSizeF(leaf_count, base) eps = np.finfo(np.float64).eps if abs(drect.width()) < eps: sx = 1.0 else: sx = crect.width() / drect.width() if abs(drect.height()) < eps: sy = 1.0 else: sy = crect.height() / drect.height() transform = QTransform().scale(sx, sy) self._transform = transform self._itemgroup.setPos(crect.topLeft()) self._itemgroup.setGeometry(crect) for node_geom in postorder(self._layout): node, _ = node_geom.value item = self._items[node] item.setGeometryData(transform.map(item.sourcePath), transform.map(item.sourceAreaShape)) self._selection_items = None self._update_selection_items() def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF: # reimplemented fm = QFontMetrics(self.font()) spacing = fm.lineSpacing() mleft, mtop, mright, mbottom = self.getContentsMargins() if self._root and which == Qt.PreferredSize: nleaves = len( [node for node in self._items.keys() if not node.branches]) base = max(10, min(spacing * 16, 250)) if self.orientation in [self.Left, self.Right]: return QSizeF(base, spacing * nleaves + mleft + mright) else: return QSizeF(spacing * nleaves + mtop + mbottom, base) elif which == Qt.MinimumSize: return QSizeF(mleft + mright + 10, mtop + mbottom + 10) else: return QSizeF() def sceneEventFilter(self, obj, event): if isinstance(obj, DendrogramWidget.ClusterGraphicsItem): if event.type() == QEvent.GraphicsSceneHoverEnter and \ self.__hoverHighlightEnabled: self._set_hover_item(obj) event.accept() return True elif event.type() == QEvent.GraphicsSceneMousePress and \ event.button() == Qt.LeftButton: is_selected = self.isItemSelected(obj) is_included = self.is_included(obj) current_selection = list(self._selection) if self.__selectionMode == DendrogramWidget.SingleSelection: if event.modifiers() & Qt.ControlModifier: self.setSelectedItems([obj] if not is_selected else []) elif event.modifiers() & Qt.AltModifier: self.setSelectedItems([]) elif event.modifiers() & Qt.ShiftModifier: if not is_included: self.setSelectedItems([obj]) elif current_selection != [obj]: self.setSelectedItems([obj]) elif self.__selectionMode == DendrogramWidget.ExtendedSelection: if event.modifiers() & Qt.ControlModifier: self.setItemSelected(obj, not is_selected) elif event.modifiers() & Qt.AltModifier: self.setItemSelected(self._selected_super_item(obj), False) elif event.modifiers() & Qt.ShiftModifier: if not is_included: self.setItemSelected(obj, True) elif current_selection != [obj]: self.setSelectedItems([obj]) if current_selection != self._selection: self.selectionEdited.emit() self.itemClicked.emit(obj) event.accept() return True if event.type() == QEvent.GraphicsSceneHoverLeave: self._set_hover_item(None) return super().sceneEventFilter(obj, event) def changeEvent(self, event): # reimplemented super().changeEvent(event) if event.type() == QEvent.FontChange: self.updateGeometry() elif event.type() == QEvent.PaletteChange: self._update_colors() elif event.type() == QEvent.ContentsRectChange: self._rescale() def resizeEvent(self, event): # reimplemented super().resizeEvent(event) self._rescale() def mousePressEvent(self, event): # reimplemented super().mousePressEvent(event) # A mouse press on an empty widget part if event.modifiers() == Qt.NoModifier and self._selection: self.set_selected_clusters([]) def _update_colors(self): def set_color(item: DendrogramWidget.ClusterGraphicsItem, color: QColor): def branches(item): return [self._items[ch] for ch in item.node.branches] for it in postorder(item, branches): it.setPen(update_pen(it.pen(), brush=color)) if self._root is not None: foreground = self.palette().color(QPalette.Foreground) item = self.item(self._root) set_color(item, foreground) highlight = self.palette().color(QPalette.Highlight) if self._highlighted_item is not None: set_color(self._highlighted_item, highlight) accent = self.palette().color(QPalette.Link) for item in self._selection.values(): item.label.setBrush(accent)
def update_rect(self, p1: QPointF, p2: QPointF): rect = QRectF(p1, p2) self.setPos(rect.topLeft()) trans = QTransform.fromScale(rect.width(), rect.height()) self.setTransform(trans)
def set_zoom(zoom): if view._zoom != zoom: view._zoom = zoom view.setTransform(QTransform.fromScale(*(view._zoom / 100, ) * 2)) zoomout.setEnabled(zoom >= 20) zoomin.setEnabled(zoom <= 300)