class DendrogramWidget(QGraphicsWidget): """A Graphics Widget displaying a dendrogram.""" class ClusterGraphicsItem(QGraphicsPathItem): _rect = None def shape(self): if self._rect is not None: p = QPainterPath() p.addRect(self.boundingRect()) return p else: return super().shape() def setRect(self, rect): self.prepareGeometryChange() self._rect = QRectF(rect) def boundingRect(self): if self._rect is not None: return QRectF(self._rect) else: return super().boundingRect() #: Orientation Left, Top, Right, Bottom = 1, 2, 3, 4 selectionChanged = Signal() selectionEdited = Signal() def __init__(self, parent=None, root=None, orientation=Left): QGraphicsWidget.__init__(self, parent) self.orientation = orientation self._root = None self._highlighted_item = None #: a list of selected items self._selection = OrderedDict() #: a {node: item} mapping self._items = {} #: container for all cluster items. self._itemgroup = QGraphicsWidget(self) self._itemgroup.setGeometry(self.contentsRect()) self._cluster_parent = {} self.setContentsMargins(5, 5, 5, 5) self.set_root(root) def clear(self): for item in self._items.values(): item.setParentItem(None) if item.scene() is self.scene() and self.scene() is not None: self.scene().removeItem(item) for item in self._selection.values(): item.setParentItem(None) if item.scene(): item.scene().removeItem(item) self._root = None self._items = {} self._selection = OrderedDict() self._highlighted_item = None self._cluster_parent = {} def set_root(self, root): """Set the root cluster. :param Tree root: Root tree. """ self.clear() self._root = root if root: pen = make_pen(Qt.blue, 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 item.installSceneEventFilter(self) for branch in node.branches: assert branch in self._items self._cluster_parent[branch] = node self._items[node] = item self.updateGeometry() self._relayout() self._rescale() def item(self, node): """Return the DendrogramNode instance representing the cluster. :type cluster: :class:`Tree` """ return self._items.get(node) def height_at(self, point): """Return the cluster height at the point in widget local coordinates. """ if not self._root: return 0 tpoint = self.mapToItem(self._itemgroup, point) if self.orientation in [self.Left, self.Right]: height = tpoint.x() else: height = tpoint.y() if self.orientation in [self.Left, self.Bottom]: base = self._root.value.height height = base - height return height def pos_at_height(self, height): """Return a point in local coordinates for `height` (in cluster height scale). """ if not self._root: return QPointF() if self.orientation in [self.Left, self.Bottom]: base = self._root.value.height height = base - height if self.orientation in [self.Left, self.Right]: p = QPointF(height, 0) else: p = QPointF(0, height) return self.mapFromItem(self._itemgroup, p) def _set_hover_item(self, item): """Set the currently highlighted item.""" if self._highlighted_item is item: return def branches(item): return [self._items[ch] for ch in item.node.branches] if self._highlighted_item: pen = make_pen(Qt.blue, width=1, cosmetic=True) for it in postorder(self._highlighted_item, branches): it.setPen(pen) self._highlighted_item = item if item: hpen = make_pen(Qt.blue, width=2, cosmetic=True) for it in postorder(item, branches): it.setPen(hpen) def leaf_items(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(()) def leaf_anchors(self): """Iterate over the dendrogram leaf anchor points (:class:`QPointF`). The points are in the widget local coordinates. """ for item in self.leaf_items(): anchor = QPointF(item.element.anchor) yield self.mapFromItem(item, anchor) def selected_nodes(self): """Return the selected clusters.""" return [item.node for item in self._selection] def set_selected_items(self, items): """Set the item selection. :param items: List of `GraphicsItems`s to select. """ 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() def set_selected_clusters(self, clusters): """Set the selected clusters. :param Tree items: List of cluster nodes to select . """ self.set_selected_items(list(map(self.item, clusters))) def select_item(self, item, state): """Set the `item`s selection state to `select_state` :param item: QGraphicsItem. :param bool state: New selection state for item. """ if state is False and item not in self._selection or \ state == True and item in self._selection: return # State unchanged if item in self._selection: if state == False: self._remove_selection(item) 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) self._re_enumerate_selections() elif item in self._selection: self._remove_selection(item) self.selectionChanged.emit() def _add_selection(self, item): """Add selection rooted at item """ outline = self._selection_poly(item) selection_item = QGraphicsPolygonItem(self) # selection_item = QGraphicsPathItem(self) selection_item.setPos(self.contentsRect().topLeft()) # selection_item.setPen(QPen(Qt.NoPen)) selection_item.setPen(make_pen(width=1, cosmetic=True)) transform = self._itemgroup.transform() path = transform.map(outline) margin = 4 if item.node.is_leaf: path = QPolygonF(path.boundingRect().adjusted( -margin, -margin, margin, margin)) else: pass # ppath = QPainterPath() # ppath.addPolygon(path) # path = path_outline(ppath, width=margin).toFillPolygon() selection_item.setPolygon(path) # selection_item.setPath(path_outline(path, width=4)) selection_item.unscaled_path = outline self._selection[item] = selection_item item.setSelected(True) def _remove_selection(self, item): """Remove selection rooted at item.""" selection_poly = self._selection[item] selection_poly.hide() selection_poly.setParentItem(None) if self.scene(): self.scene().removeItem(selection_poly) del self._selection[item] item.setSelected(False) self._re_enumerate_selections() 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 = colorpalette.ColorPaletteGenerator(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 color = palette[i] color.setAlpha(150) selection_item.setBrush(QColor(color)) def _selection_poly(self, item): """Return an selection item covering the selection rooted at item. """ def branches(item): return [self._items[ch] for ch in item.node.branches] 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:]] allitems = list(preorder(item, left)) + list(preorder(item, right))[1:] if len(allitems) == 1: assert (allitems[0].node.is_leaf) else: allitems = [item for item in allitems if not item.node.is_leaf] brects = [QPolygonF(item.boundingRect()) for item in allitems] return reduce(QPolygonF.united, brects, QPolygonF()) def _update_selection_items(self): """Update the shapes of selection items after a scale change. """ transform = self._itemgroup.transform() for _, selection in self._selection.items(): path = transform.map(selection.unscaled_path) selection.setPolygon(path) # selection.setPath(path) # selection.setPath(path_outline(path, width=4)) def _relayout(self): if not self._root: return self._layout = dendrogram_path(self._root, self.orientation) for node_geom in postorder(self._layout): node, geom = node_geom.value item = self._items[node] item.element = geom item.setPath(Path_toQtPath(geom)) item.setZValue(-node.value.height) item.setPen(QPen(Qt.blue)) r = item.boundingRect() base = self._root.value.height 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) item.setRect(r) def _rescale(self): if self._root is None: return crect = self.contentsRect() leaf_count = len(list(leaves(self._root))) if self.orientation in [Left, Right]: drect = QSizeF(self._root.value.height, leaf_count - 1) else: drect = QSizeF(self._root.value.last - 1, self._root.value.height) transform = QTransform().scale(crect.width() / drect.width(), crect.height() / drect.height()) self._itemgroup.setPos(crect.topLeft()) self._itemgroup.setTransform(transform) self._selection_items = None self._update_selection_items() def sizeHint(self, which, constraint=QSizeF()): 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]) if self.orientation in [self.Left, self.Right]: return QSizeF(250, spacing * nleaves + mleft + mright) else: return QSizeF(spacing * nleaves + mtop + mbottom, 250) 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: self._set_hover_item(obj) event.accept() return True elif event.type() == QEvent.GraphicsSceneMousePress and \ event.button() == Qt.LeftButton: if event.modifiers() & Qt.ControlModifier: self.select_item(obj, not obj.isSelected()) else: self.set_selected_items([obj]) self.selectionEdited.emit() assert self._highlighted_item is obj event.accept() return True if event.type() == QEvent.GraphicsSceneHoverLeave: self._set_hover_item(None) return super().sceneEventFilter(obj, event) def changeEvent(self, event): super().changeEvent(event) if event.type() == QEvent.FontChange: self.updateGeometry() def resizeEvent(self, event): super().resizeEvent(event) self._rescale() def mousePressEvent(self, event): QGraphicsWidget.mousePressEvent(self, event) # A mouse press on an empty widget part if event.modifiers() == Qt.NoModifier and self._selection: self.set_selected_clusters([])
class DendrogramWidget(QGraphicsWidget): """A Graphics Widget displaying a dendrogram.""" class ClusterGraphicsItem(QGraphicsPathItem): _rect = None def shape(self): if self._rect is not None: p = QPainterPath() p.addRect(self.boundingRect()) return p else: return super().shape() def setRect(self, rect): self.prepareGeometryChange() self._rect = QRectF(rect) def boundingRect(self): if self._rect is not None: return QRectF(self._rect) else: return super().boundingRect() #: Orientation Left, Top, Right, Bottom = 1, 2, 3, 4 selectionChanged = Signal() selectionEdited = Signal() def __init__(self, parent=None, root=None, orientation=Left): QGraphicsWidget.__init__(self, parent) self.orientation = orientation self._root = None self._highlighted_item = None #: a list of selected items self._selection = OrderedDict() #: a {node: item} mapping self._items = {} #: container for all cluster items. self._itemgroup = QGraphicsWidget(self) self._itemgroup.setGeometry(self.contentsRect()) self._cluster_parent = {} self.setContentsMargins(5, 5, 5, 5) self.set_root(root) def clear(self): for item in self._items.values(): item.setParentItem(None) if item.scene() is self.scene() and self.scene() is not None: self.scene().removeItem(item) for item in self._selection.values(): item.setParentItem(None) if item.scene(): item.scene().removeItem(item) self._root = None self._items = {} self._selection = OrderedDict() self._highlighted_item = None self._cluster_parent = {} def set_root(self, root): """Set the root cluster. :param Tree root: Root tree. """ self.clear() self._root = root if root: pen = make_pen(Qt.blue, 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 item.installSceneEventFilter(self) for branch in node.branches: assert branch in self._items self._cluster_parent[branch] = node self._items[node] = item self.updateGeometry() self._relayout() self._rescale() def item(self, node): """Return the DendrogramNode instance representing the cluster. :type cluster: :class:`Tree` """ return self._items.get(node) def height_at(self, point): """Return the cluster height at the point in widget local coordinates. """ if not self._root: return 0 tpoint = self.mapToItem(self._itemgroup, point) if self.orientation in [self.Left, self.Right]: height = tpoint.x() else: height = tpoint.y() if self.orientation in [self.Left, self.Bottom]: base = self._root.value.height height = base - height return height def pos_at_height(self, height): """Return a point in local coordinates for `height` (in cluster height scale). """ if not self._root: return QPointF() if self.orientation in [self.Left, self.Bottom]: base = self._root.value.height height = base - height if self.orientation in [self.Left, self.Right]: p = QPointF(height, 0) else: p = QPointF(0, height) return self.mapFromItem(self._itemgroup, p) def _set_hover_item(self, item): """Set the currently highlighted item.""" if self._highlighted_item is item: return def branches(item): return [self._items[ch] for ch in item.node.branches] if self._highlighted_item: pen = make_pen(Qt.blue, width=1, cosmetic=True) for it in postorder(self._highlighted_item, branches): it.setPen(pen) self._highlighted_item = item if item: hpen = make_pen(Qt.blue, width=2, cosmetic=True) for it in postorder(item, branches): it.setPen(hpen) def leaf_items(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(()) def leaf_anchors(self): """Iterate over the dendrogram leaf anchor points (:class:`QPointF`). The points are in the widget local coordinates. """ for item in self.leaf_items(): anchor = QPointF(item.element.anchor) yield self.mapFromItem(item, anchor) def selected_nodes(self): """Return the selected clusters.""" return [item.node for item in self._selection] def set_selected_items(self, items): """Set the item selection. :param items: List of `GraphicsItems`s to select. """ 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() def set_selected_clusters(self, clusters): """Set the selected clusters. :param Tree items: List of cluster nodes to select . """ self.set_selected_items(list(map(self.item, clusters))) def select_item(self, item, state): """Set the `item`s selection state to `select_state` :param item: QGraphicsItem. :param bool state: New selection state for item. """ if state is False and item not in self._selection or \ state == True and item in self._selection: return # State unchanged if item in self._selection: if state == False: self._remove_selection(item) 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) self._re_enumerate_selections() elif item in self._selection: self._remove_selection(item) self.selectionChanged.emit() def _add_selection(self, item): """Add selection rooted at item """ outline = self._selection_poly(item) selection_item = QGraphicsPolygonItem(self) # selection_item = QGraphicsPathItem(self) selection_item.setPos(self.contentsRect().topLeft()) # selection_item.setPen(QPen(Qt.NoPen)) selection_item.setPen(make_pen(width=1, cosmetic=True)) transform = self._itemgroup.transform() path = transform.map(outline) margin = 4 if item.node.is_leaf: path = QPolygonF(path.boundingRect() .adjusted(-margin, -margin, margin, margin)) else: pass # ppath = QPainterPath() # ppath.addPolygon(path) # path = path_outline(ppath, width=margin).toFillPolygon() selection_item.setPolygon(path) # selection_item.setPath(path_outline(path, width=4)) selection_item.unscaled_path = outline self._selection[item] = selection_item item.setSelected(True) def _remove_selection(self, item): """Remove selection rooted at item.""" selection_poly = self._selection[item] selection_poly.hide() selection_poly.setParentItem(None) if self.scene(): self.scene().removeItem(selection_poly) del self._selection[item] item.setSelected(False) self._re_enumerate_selections() 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 = colorpalette.ColorPaletteGenerator(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 color = palette[i] color.setAlpha(150) selection_item.setBrush(QColor(color)) def _selection_poly(self, item): """Return an selection item covering the selection rooted at item. """ def branches(item): return [self._items[ch] for ch in item.node.branches] 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:]] allitems = list(preorder(item, left)) + list(preorder(item, right))[1:] if len(allitems) == 1: assert(allitems[0].node.is_leaf) else: allitems = [item for item in allitems if not item.node.is_leaf] brects = [QPolygonF(item.boundingRect()) for item in allitems] return reduce(QPolygonF.united, brects, QPolygonF()) def _update_selection_items(self): """Update the shapes of selection items after a scale change. """ transform = self._itemgroup.transform() for _, selection in self._selection.items(): path = transform.map(selection.unscaled_path) selection.setPolygon(path) # selection.setPath(path) # selection.setPath(path_outline(path, width=4)) def _relayout(self): if not self._root: return self._layout = dendrogram_path(self._root, self.orientation) for node_geom in postorder(self._layout): node, geom = node_geom.value item = self._items[node] item.element = geom item.setPath(Path_toQtPath(geom)) item.setZValue(-node.value.height) item.setPen(QPen(Qt.blue)) r = item.boundingRect() base = self._root.value.height 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) item.setRect(r) def _rescale(self): if self._root is None: return crect = self.contentsRect() leaf_count = len(list(leaves(self._root))) if self.orientation in [Left, Right]: drect = QSizeF(self._root.value.height, leaf_count - 1) else: drect = QSizeF(self._root.value.last - 1, self._root.value.height) transform = QTransform().scale( crect.width() / drect.width(), crect.height() / drect.height() ) self._itemgroup.setPos(crect.topLeft()) self._itemgroup.setTransform(transform) self._selection_items = None self._update_selection_items() def sizeHint(self, which, constraint=QSizeF()): 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]) if self.orientation in [self.Left, self.Right]: return QSizeF(250, spacing * nleaves + mleft + mright) else: return QSizeF(spacing * nleaves + mtop + mbottom, 250) 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: self._set_hover_item(obj) event.accept() return True elif event.type() == QEvent.GraphicsSceneMousePress and \ event.button() == Qt.LeftButton: if event.modifiers() & Qt.ControlModifier: self.select_item(obj, not obj.isSelected()) else: self.set_selected_items([obj]) self.selectionEdited.emit() assert self._highlighted_item is obj event.accept() return True if event.type() == QEvent.GraphicsSceneHoverLeave: self._set_hover_item(None) return super().sceneEventFilter(obj, event) def changeEvent(self, event): super().changeEvent(event) if event.type() == QEvent.FontChange: self.updateGeometry() def resizeEvent(self, event): super().resizeEvent(event) self._rescale() def mousePressEvent(self, event): QGraphicsWidget.mousePressEvent(self, event) # A mouse press on an empty widget part if event.modifiers() == Qt.NoModifier and self._selection: self.set_selected_clusters([])