1from itertools import chain
2from contextlib import contextmanager
3
4import typing
5from typing import Any, List, Tuple, Dict, Optional, Set, Union
6
7import numpy as np
8
9from AnyQt.QtWidgets import (
10    QGraphicsWidget, QGraphicsObject, QGraphicsScene, QGridLayout, QSizePolicy,
11    QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent
12)
13from AnyQt.QtGui import QColor, QPen, QFont, QKeySequence
14from AnyQt.QtCore import Qt, QSize, QSizeF, QPointF, QRectF, QLineF, QEvent
15from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
16
17import pyqtgraph as pg
18
19import Orange.data
20from Orange.data.domain import filter_visible
21from Orange.data import Domain
22import Orange.misc
23from Orange.clustering.hierarchical import \
24    postorder, preorder, Tree, tree_from_linkage, dist_matrix_linkage, \
25    leaves, prune, top_clusters
26from Orange.data.util import get_unique_names
27
28from Orange.widgets import widget, gui, settings
29from Orange.widgets.utils import itemmodels, combobox
30from Orange.widgets.utils.annotated_data import (create_annotated_table,
31                                                 ANNOTATED_DATA_SIGNAL_NAME)
32from Orange.widgets.utils.widgetpreview import WidgetPreview
33from Orange.widgets.widget import Input, Output, Msg
34
35from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView
36from Orange.widgets.utils.graphicstextlist import TextListWidget
37from Orange.widgets.utils.dendrogram import DendrogramWidget
38
39__all__ = ["OWHierarchicalClustering"]
40
41
42LINKAGE = ["Single", "Average", "Weighted", "Complete", "Ward"]
43
44
45def make_pen(brush=Qt.black, width=1, style=Qt.SolidLine,
46             cap_style=Qt.SquareCap, join_style=Qt.BevelJoin,
47             cosmetic=False):
48    pen = QPen(brush)
49    pen.setWidth(width)
50    pen.setStyle(style)
51    pen.setCapStyle(cap_style)
52    pen.setJoinStyle(join_style)
53    pen.setCosmetic(cosmetic)
54    return pen
55
56
57@contextmanager
58def blocked(obj):
59    old = obj.signalsBlocked()
60    obj.blockSignals(True)
61    try:
62        yield obj
63    finally:
64        obj.blockSignals(old)
65
66
67class SaveStateSettingsHandler(settings.SettingsHandler):
68    """
69    A settings handler that delegates session data store/restore to the
70    OWWidget instance.
71
72    The OWWidget subclass must implement `save_state() -> Dict[str, Any]` and
73    `set_restore_state(state: Dict[str, Any])` methods.
74    """
75    def initialize(self, instance, data=None):
76        super().initialize(instance, data)
77        if data is not None and "__session_state_data" in data:
78            session_data = data["__session_state_data"]
79            instance.set_restore_state(session_data)
80
81    def pack_data(self, widget):
82        # type: (widget.OWWidget) -> dict
83        res = super().pack_data(widget)
84        state = widget.save_state()
85        if state:
86            assert "__session_state_data" not in res
87            res["__session_state_data"] = state
88        return res
89
90
91class _DomainContextHandler(settings.DomainContextHandler,
92                            SaveStateSettingsHandler):
93    pass
94
95
96if typing.TYPE_CHECKING:
97    #: Encoded selection state for persistent storage.
98    #: This is a list of tuples of leaf indices in the selection and
99    #: a (N, 3) linkage matrix for validation (the 4-th column from scipy
100    #: is omitted).
101    SelectionState = Tuple[List[Tuple[int]], List[Tuple[int, int, float]]]
102
103
104class OWHierarchicalClustering(widget.OWWidget):
105    name = "Hierarchical Clustering"
106    description = "Display a dendrogram of a hierarchical clustering " \
107                  "constructed from the input distance matrix."
108    icon = "icons/HierarchicalClustering.svg"
109    priority = 2100
110    keywords = []
111
112    class Inputs:
113        distances = Input("Distances", Orange.misc.DistMatrix)
114
115    class Outputs:
116        selected_data = Output("Selected Data", Orange.data.Table, default=True)
117        annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table)
118
119    settingsHandler = _DomainContextHandler()
120
121    #: Selected linkage
122    linkage = settings.Setting(1)
123    #: Index of the selected annotation item (variable, ...)
124    annotation = settings.ContextSetting("Enumeration")
125    #: Out-of-context setting for the case when the "Name" option is available
126    annotation_if_names = settings.Setting("Name")
127    #: Out-of-context setting for the case with just "Enumerate" and "None"
128    annotation_if_enumerate = settings.Setting("Enumerate")
129    #: Selected tree pruning (none/max depth)
130    pruning = settings.Setting(0)
131    #: Maximum depth when max depth pruning is selected
132    max_depth = settings.Setting(10)
133
134    #: Selected cluster selection method (none, cut distance, top n)
135    selection_method = settings.Setting(0)
136    #: Cut height ratio wrt root height
137    cut_ratio = settings.Setting(75.0)
138    #: Number of top clusters to select
139    top_n = settings.Setting(3)
140    #: Dendrogram zoom factor
141    zoom_factor = settings.Setting(0)
142
143    autocommit = settings.Setting(True)
144
145    graph_name = "scene"
146
147    basic_annotations = ["None", "Enumeration"]
148
149    class Error(widget.OWWidget.Error):
150        not_finite_distances = Msg("Some distances are infinite")
151
152    #: Stored (manual) selection state (from a saved workflow) to restore.
153    __pending_selection_restore = None  # type: Optional[SelectionState]
154
155    def __init__(self):
156        super().__init__()
157
158        self.matrix = None
159        self.items = None
160        self.linkmatrix = None
161        self.root = None
162        self._displayed_root = None
163        self.cutoff_height = 0.0
164
165        gui.comboBox(
166            self.controlArea, self, "linkage", items=LINKAGE, box="Linkage",
167            callback=self._invalidate_clustering)
168
169        model = itemmodels.VariableListModel()
170        model[:] = self.basic_annotations
171
172        box = gui.widgetBox(self.controlArea, "Annotations")
173        self.label_cb = cb = combobox.ComboBoxSearch(
174            minimumContentsLength=14,
175            sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon
176        )
177        cb.setModel(model)
178        cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole))
179
180        def on_annotation_activated():
181            self.annotation = cb.currentData(Qt.EditRole)
182            self._update_labels()
183        cb.activated.connect(on_annotation_activated)
184
185        def on_annotation_changed(value):
186            cb.setCurrentIndex(cb.findData(value, Qt.EditRole))
187        self.connect_control("annotation", on_annotation_changed)
188
189        box.layout().addWidget(self.label_cb)
190
191        box = gui.radioButtons(
192            self.controlArea, self, "pruning", box="Pruning",
193            callback=self._invalidate_pruning)
194        grid = QGridLayout()
195        box.layout().addLayout(grid)
196        grid.addWidget(
197            gui.appendRadioButton(box, "None", addToLayout=False),
198            0, 0
199        )
200        self.max_depth_spin = gui.spin(
201            box, self, "max_depth", minv=1, maxv=100,
202            callback=self._invalidate_pruning,
203            keyboardTracking=False, addToLayout=False
204        )
205
206        grid.addWidget(
207            gui.appendRadioButton(box, "Max depth:", addToLayout=False),
208            1, 0)
209        grid.addWidget(self.max_depth_spin, 1, 1)
210
211        self.selection_box = gui.radioButtons(
212            self.controlArea, self, "selection_method",
213            box="Selection",
214            callback=self._selection_method_changed)
215
216        grid = QGridLayout()
217        self.selection_box.layout().addLayout(grid)
218        grid.addWidget(
219            gui.appendRadioButton(
220                self.selection_box, "Manual", addToLayout=False),
221            0, 0
222        )
223        grid.addWidget(
224            gui.appendRadioButton(
225                self.selection_box, "Height ratio:", addToLayout=False),
226            1, 0
227        )
228        self.cut_ratio_spin = gui.spin(
229            self.selection_box, self, "cut_ratio", 0, 100, step=1e-1,
230            spinType=float, callback=self._selection_method_changed,
231            addToLayout=False
232        )
233        self.cut_ratio_spin.setSuffix("%")
234
235        grid.addWidget(self.cut_ratio_spin, 1, 1)
236
237        grid.addWidget(
238            gui.appendRadioButton(
239                self.selection_box, "Top N:", addToLayout=False),
240            2, 0
241        )
242        self.top_n_spin = gui.spin(self.selection_box, self, "top_n", 1, 20,
243                                   callback=self._selection_method_changed,
244                                   addToLayout=False)
245        grid.addWidget(self.top_n_spin, 2, 1)
246
247        self.zoom_slider = gui.hSlider(
248            self.controlArea, self, "zoom_factor", box="Zoom",
249            minValue=-6, maxValue=3, step=1, ticks=True, createLabel=False,
250            callback=self.__update_font_scale)
251
252        zoom_in = QAction(
253            "Zoom in", self, shortcut=QKeySequence.ZoomIn,
254            triggered=self.__zoom_in
255        )
256        zoom_out = QAction(
257            "Zoom out", self, shortcut=QKeySequence.ZoomOut,
258            triggered=self.__zoom_out
259        )
260        zoom_reset = QAction(
261            "Reset zoom", self,
262            shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_0),
263            triggered=self.__zoom_reset
264        )
265        self.addActions([zoom_in, zoom_out, zoom_reset])
266
267        self.controlArea.layout().addStretch()
268
269        gui.auto_send(self.buttonsArea, self, "autocommit")
270
271        self.scene = QGraphicsScene(self)
272        self.view = StickyGraphicsView(
273            self.scene,
274            horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff,
275            verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn,
276            alignment=Qt.AlignLeft | Qt.AlignVCenter
277        )
278        self.mainArea.layout().setSpacing(1)
279        self.mainArea.layout().addWidget(self.view)
280
281        def axis_view(orientation):
282            ax = AxisItem(orientation=orientation, maxTickLength=7)
283            ax.mousePressed.connect(self._activate_cut_line)
284            ax.mouseMoved.connect(self._activate_cut_line)
285            ax.mouseReleased.connect(self._activate_cut_line)
286            ax.setRange(1.0, 0.0)
287            return ax
288
289        self.top_axis = axis_view("top")
290        self.bottom_axis = axis_view("bottom")
291
292        self._main_graphics = QGraphicsWidget()
293        scenelayout = QGraphicsGridLayout()
294        scenelayout.setHorizontalSpacing(10)
295        scenelayout.setVerticalSpacing(10)
296
297        self._main_graphics.setLayout(scenelayout)
298        self.scene.addItem(self._main_graphics)
299
300        self.dendrogram = DendrogramWidget()
301        self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding,
302                                      QSizePolicy.MinimumExpanding)
303        self.dendrogram.selectionChanged.connect(self._invalidate_output)
304        self.dendrogram.selectionEdited.connect(self._selection_edited)
305
306        self.labels = TextListWidget()
307        self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
308        self.labels.setAlignment(Qt.AlignLeft)
309        self.labels.setMaximumWidth(200)
310
311        scenelayout.addItem(self.top_axis, 0, 0,
312                            alignment=Qt.AlignLeft | Qt.AlignVCenter)
313        scenelayout.addItem(self.dendrogram, 1, 0,
314                            alignment=Qt.AlignLeft | Qt.AlignVCenter)
315        scenelayout.addItem(self.labels, 1, 1,
316                            alignment=Qt.AlignLeft | Qt.AlignVCenter)
317        scenelayout.addItem(self.bottom_axis, 2, 0,
318                            alignment=Qt.AlignLeft | Qt.AlignVCenter)
319        self.view.viewport().installEventFilter(self)
320        self._main_graphics.installEventFilter(self)
321
322        self.top_axis.setZValue(self.dendrogram.zValue() + 10)
323        self.bottom_axis.setZValue(self.dendrogram.zValue() + 10)
324        self.cut_line = SliderLine(self.top_axis,
325                                   orientation=Qt.Horizontal)
326        self.cut_line.valueChanged.connect(self._dendrogram_slider_changed)
327        self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed)
328        self._set_cut_line_visible(self.selection_method == 1)
329        self.__update_font_scale()
330
331    @Inputs.distances
332    def set_distances(self, matrix):
333        if self.__pending_selection_restore is not None:
334            selection_state = self.__pending_selection_restore
335        else:
336            # save the current selection to (possibly) restore later
337            selection_state = self._save_selection()
338
339        self.error()
340        self.Error.clear()
341        if matrix is not None:
342            N, _ = matrix.shape
343            if N < 2:
344                self.error("Empty distance matrix")
345                matrix = None
346        if matrix is not None:
347            if not np.all(np.isfinite(matrix)):
348                self.Error.not_finite_distances()
349                matrix = None
350
351        self.matrix = matrix
352        if matrix is not None:
353            self._set_items(matrix.row_items, matrix.axis)
354        else:
355            self._set_items(None)
356        self._invalidate_clustering()
357
358        # Can now attempt to restore session state from a saved workflow.
359        if self.root and selection_state is not None:
360            self._restore_selection(selection_state)
361            self.__pending_selection_restore = None
362
363        self.unconditional_commit()
364
365    def _set_items(self, items, axis=1):
366        self.closeContext()
367        self.items = items
368        model = self.label_cb.model()
369        if len(model) == 3:
370            self.annotation_if_names = self.annotation
371        elif len(model) == 2:
372            self.annotation_if_enumerate = self.annotation
373        if isinstance(items, Orange.data.Table) and axis:
374            model[:] = chain(
375                self.basic_annotations,
376                [model.Separator],
377                items.domain.class_vars,
378                items.domain.metas,
379                [model.Separator] if (items.domain.class_vars or items.domain.metas) and
380                next(filter_visible(items.domain.attributes), False) else [],
381                filter_visible(items.domain.attributes)
382            )
383            if items.domain.class_vars:
384                self.annotation = items.domain.class_vars[0]
385            else:
386                self.annotation = "Enumeration"
387            self.openContext(items.domain)
388        else:
389            name_option = bool(
390                items is not None and (
391                    not axis or
392                    isinstance(items, list) and
393                    all(isinstance(var, Orange.data.Variable) for var in items)))
394            model[:] = self.basic_annotations + ["Name"] * name_option
395            self.annotation = self.annotation_if_names if name_option \
396                else self.annotation_if_enumerate
397
398    def _clear_plot(self):
399        self.dendrogram.set_root(None)
400        self.labels.setItems([])
401
402    def _set_displayed_root(self, root):
403        self._clear_plot()
404        self._displayed_root = root
405        self.dendrogram.set_root(root)
406
407        self._update_labels()
408
409        self._main_graphics.resize(
410            self._main_graphics.size().width(),
411            self._main_graphics.sizeHint(Qt.PreferredSize).height()
412        )
413        self._main_graphics.layout().activate()
414
415    def _update(self):
416        self._clear_plot()
417
418        distances = self.matrix
419
420        if distances is not None:
421            method = LINKAGE[self.linkage].lower()
422            Z = dist_matrix_linkage(distances, linkage=method)
423
424            tree = tree_from_linkage(Z)
425            self.linkmatrix = Z
426            self.root = tree
427
428            self.top_axis.setRange(tree.value.height, 0.0)
429            self.bottom_axis.setRange(tree.value.height, 0.0)
430
431            if self.pruning:
432                self._set_displayed_root(prune(tree, level=self.max_depth))
433            else:
434                self._set_displayed_root(tree)
435        else:
436            self.linkmatrix = None
437            self.root = None
438            self._set_displayed_root(None)
439
440        self._apply_selection()
441
442    def _update_labels(self):
443        labels = []
444        if self.root and self._displayed_root:
445            indices = [leaf.value.index for leaf in leaves(self.root)]
446
447            if self.annotation == "None":
448                labels = []
449            elif self.annotation == "Enumeration":
450                labels = [str(i+1) for i in indices]
451            elif self.annotation == "Name":
452                attr = self.matrix.row_items.domain.attributes
453                labels = [str(attr[i]) for i in indices]
454            elif isinstance(self.annotation, Orange.data.Variable):
455                col_data, _ = self.items.get_column_view(self.annotation)
456                labels = [self.annotation.str_val(val) for val in col_data]
457                labels = [labels[idx] for idx in indices]
458            else:
459                labels = []
460
461            if labels and self._displayed_root is not self.root:
462                joined = leaves(self._displayed_root)
463                labels = [", ".join(labels[leaf.value.first: leaf.value.last])
464                          for leaf in joined]
465
466        self.labels.setItems(labels)
467        self.labels.setMinimumWidth(1 if labels else -1)
468
469    def _restore_selection(self, state):
470        # type: (SelectionState) -> bool
471        """
472        Restore the (manual) node selection state.
473
474        Return True if successful; False otherwise.
475        """
476        linkmatrix = self.linkmatrix
477        if self.selection_method == 0 and self.root:
478            selected, linksaved = state
479            linkstruct = np.array(linksaved, dtype=float)
480            selected = set(selected)  # type: Set[Tuple[int]]
481            if not selected:
482                return False
483            if linkmatrix.shape[0] != linkstruct.shape[0]:
484                return False
485            # check that the linkage matrix structure matches. Use isclose for
486            # the height column to account for inexact floating point math
487            # (e.g. summation order in different ?gemm implementations for
488            # euclidean distances, ...)
489            if np.any(linkstruct[:, :2] != linkmatrix[:, :2]) or \
490                    not np.all(np.isclose(linkstruct[:, 2], linkstruct[:, 2])):
491                return False
492            selection = []
493            indices = np.array([n.value.index for n in leaves(self.root)],
494                               dtype=int)
495            # mapping from ranges to display (pruned) nodes
496            mapping = {node.value.range: node
497                       for node in postorder(self._displayed_root)}
498            for node in postorder(self.root):  # type: Tree
499                r = tuple(indices[node.value.first: node.value.last])
500                if r in selected:
501                    if node.value.range not in mapping:
502                        # the node was pruned from display and cannot be
503                        # selected
504                        break
505                    selection.append(mapping[node.value.range])
506                    selected.remove(r)
507                if not selected:
508                    break  # found all, nothing more to do
509            if selection and selected:
510                # Could not restore all selected nodes (only partial match)
511                return False
512
513            self._set_selected_nodes(selection)
514            return True
515        return False
516
517    def _set_selected_nodes(self, selection):
518        # type: (List[Tree]) -> None
519        """
520        Set the nodes in `selection` to be the current selected nodes.
521
522        The selection nodes must be subtrees of the current `_displayed_root`.
523        """
524        self.dendrogram.selectionChanged.disconnect(self._invalidate_output)
525        try:
526            self.dendrogram.set_selected_clusters(selection)
527        finally:
528            self.dendrogram.selectionChanged.connect(self._invalidate_output)
529
530    def _invalidate_clustering(self):
531        self._update()
532        self._update_labels()
533        self._invalidate_output()
534
535    def _invalidate_output(self):
536        self.commit()
537
538    def _invalidate_pruning(self):
539        if self.root:
540            selection = self.dendrogram.selected_nodes()
541            ranges = [node.value.range for node in selection]
542            if self.pruning:
543                self._set_displayed_root(
544                    prune(self.root, level=self.max_depth))
545            else:
546                self._set_displayed_root(self.root)
547            selected = [node for node in preorder(self._displayed_root)
548                        if node.value.range in ranges]
549
550            self.dendrogram.set_selected_clusters(selected)
551
552        self._apply_selection()
553
554    def commit(self):
555        items = getattr(self.matrix, "items", self.items)
556        if not items:
557            self.Outputs.selected_data.send(None)
558            self.Outputs.annotated_data.send(None)
559            return
560
561        selection = self.dendrogram.selected_nodes()
562        selection = sorted(selection, key=lambda c: c.value.first)
563
564        indices = [leaf.value.index for leaf in leaves(self.root)]
565
566        maps = [indices[node.value.first:node.value.last]
567                for node in selection]
568
569        selected_indices = list(chain(*maps))
570        unselected_indices = sorted(set(range(self.root.value.last)) -
571                                    set(selected_indices))
572
573        if not selected_indices:
574            self.Outputs.selected_data.send(None)
575            annotated_data = create_annotated_table(items, []) \
576                if self.selection_method == 0 and self.matrix.axis else None
577            self.Outputs.annotated_data.send(annotated_data)
578            return
579
580        selected_data = None
581
582        if isinstance(items, Orange.data.Table) and self.matrix.axis == 1:
583            # Select rows
584            c = np.zeros(self.matrix.shape[0])
585
586            for i, indices in enumerate(maps):
587                c[indices] = i
588            c[unselected_indices] = len(maps)
589
590            mask = c != len(maps)
591
592            data, domain = items, items.domain
593            attrs = domain.attributes
594            classes = domain.class_vars
595            metas = domain.metas
596
597            var_name = get_unique_names(domain, "Cluster")
598            values = [f"C{i + 1}" for i in range(len(maps))]
599
600            clust_var = Orange.data.DiscreteVariable(
601                var_name, values=values + ["Other"])
602            domain = Orange.data.Domain(attrs, classes, metas + (clust_var,))
603            data = items.transform(domain)
604            data.get_column_view(clust_var)[0][:] = c
605
606            if selected_indices:
607                selected_data = data[mask]
608                clust_var = Orange.data.DiscreteVariable(
609                    var_name, values=values)
610                selected_data.domain = Domain(
611                    attrs, classes, metas + (clust_var, ))
612
613        elif isinstance(items, Orange.data.Table) and self.matrix.axis == 0:
614            # Select columns
615            domain = Orange.data.Domain(
616                [items.domain[i] for i in selected_indices],
617                items.domain.class_vars, items.domain.metas)
618            selected_data = items.from_table(domain, items)
619            data = None
620
621        self.Outputs.selected_data.send(selected_data)
622        annotated_data = create_annotated_table(data, selected_indices)
623        self.Outputs.annotated_data.send(annotated_data)
624
625    def eventFilter(self, obj, event):
626        if obj is self.view.viewport() and event.type() == QEvent.Resize:
627            # NOTE: not using viewport.width(), due to 'transient' scroll bars
628            # (macOS). Viewport covers the whole view, but QGraphicsView still
629            # scrolls left, right with scroll bar extent (other
630            # QAbstractScrollArea widgets behave as expected).
631            w_frame = self.view.frameWidth()
632            margin = self.view.viewportMargins()
633            w_scroll = self.view.verticalScrollBar().width()
634            width = (self.view.width() - w_frame * 2 -
635                     margin.left() - margin.right() - w_scroll)
636            # layout with new width constraint
637            self.__layout_main_graphics(width=width)
638        elif obj is self._main_graphics and \
639                event.type() == QEvent.LayoutRequest:
640            # layout preserving the width (vertical re layout)
641            self.__layout_main_graphics()
642        return super().eventFilter(obj, event)
643
644    @Slot(QPointF)
645    def _activate_cut_line(self, pos: QPointF):
646        """Activate cut line selection an set cut value to `pos.x()`."""
647        self.selection_method = 1
648        self.cut_line.setValue(pos.x())
649        self._selection_method_changed()
650
651    def onDeleteWidget(self):
652        super().onDeleteWidget()
653        self._clear_plot()
654        self.dendrogram.clear()
655        self.dendrogram.deleteLater()
656
657    def _dendrogram_geom_changed(self):
658        pos = self.dendrogram.pos_at_height(self.cutoff_height)
659        geom = self.dendrogram.geometry()
660        self._set_slider_value(pos.x(), geom.width())
661
662        self.cut_line.setLength(
663            self.bottom_axis.geometry().bottom()
664            - self.top_axis.geometry().top()
665        )
666
667        geom = self._main_graphics.geometry()
668        assert geom.topLeft() == QPointF(0, 0)
669
670        def adjustLeft(rect):
671            rect = QRectF(rect)
672            rect.setLeft(geom.left())
673            return rect
674        margin = 3
675        self.scene.setSceneRect(geom)
676        self.view.setSceneRect(geom)
677        self.view.setHeaderSceneRect(
678            adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, margin)
679        )
680        self.view.setFooterSceneRect(
681            adjustLeft(self.bottom_axis.geometry()).adjusted(0, -margin, 0, 0)
682        )
683
684    def _dendrogram_slider_changed(self, value):
685        p = QPointF(value, 0)
686        cl_height = self.dendrogram.height_at(p)
687
688        self.set_cutoff_height(cl_height)
689
690    def _set_slider_value(self, value, span):
691        with blocked(self.cut_line):
692            self.cut_line.setRange(0, span)
693            self.cut_line.setValue(value)
694
695    def set_cutoff_height(self, height):
696        self.cutoff_height = height
697        if self.root:
698            self.cut_ratio = 100 * height / self.root.value.height
699        self.select_max_height(height)
700
701    def _set_cut_line_visible(self, visible):
702        self.cut_line.setVisible(visible)
703
704    def select_top_n(self, n):
705        root = self._displayed_root
706        if root:
707            clusters = top_clusters(root, n)
708            self.dendrogram.set_selected_clusters(clusters)
709
710    def select_max_height(self, height):
711        root = self._displayed_root
712        if root:
713            clusters = clusters_at_height(root, height)
714            self.dendrogram.set_selected_clusters(clusters)
715
716    def _selection_method_changed(self):
717        self._set_cut_line_visible(self.selection_method == 1)
718        if self.root:
719            self._apply_selection()
720
721    def _apply_selection(self):
722        if not self.root:
723            return
724
725        if self.selection_method == 0:
726            pass
727        elif self.selection_method == 1:
728            height = self.cut_ratio * self.root.value.height / 100
729            self.set_cutoff_height(height)
730            pos = self.dendrogram.pos_at_height(height)
731            self._set_slider_value(pos.x(), self.dendrogram.size().width())
732        elif self.selection_method == 2:
733            self.select_top_n(self.top_n)
734
735    def _selection_edited(self):
736        # Selection was edited by clicking on a cluster in the
737        # dendrogram view.
738        self.selection_method = 0
739        self._selection_method_changed()
740        self._invalidate_output()
741
742    def _save_selection(self):
743        # Save the current manual node selection state
744        selection_state = None
745        if self.selection_method == 0 and self.root:
746            assert self.linkmatrix is not None
747            linkmat = [(int(_0), int(_1), _2)
748                       for _0, _1, _2 in self.linkmatrix[:, :3].tolist()]
749            nodes_ = self.dendrogram.selected_nodes()
750            # match the display (pruned) nodes back (by ranges)
751            mapping = {node.value.range: node for node in postorder(self.root)}
752            nodes = [mapping[node.value.range] for node in nodes_]
753            indices = [tuple(node.value.index for node in leaves(node))
754                       for node in nodes]
755            if nodes:
756                selection_state = (indices, linkmat)
757        return selection_state
758
759    def save_state(self):
760        # type: () -> Dict[str, Any]
761        """
762        Save state for `set_restore_state`
763        """
764        selection = self._save_selection()
765        res = {"version": (0, 0, 0)}
766        if selection is not None:
767            res["selection_state"] = selection
768        return res
769
770    def set_restore_state(self, state):
771        # type: (Dict[str, Any]) -> bool
772        """
773        Restore session data from a saved state.
774
775        Parameters
776        ----------
777        state : Dict[str, Any]
778
779        NOTE
780        ----
781        This is method called while the instance (self) is being constructed,
782        even before its `__init__` is called. Consider `self` to be only a
783        `QObject` at this stage.
784        """
785        if "selection_state" in state:
786            selection = state["selection_state"]
787            self.__pending_selection_restore = selection
788        return True
789
790    def __zoom_in(self):
791        def clip(minval, maxval, val):
792            return min(max(val, minval), maxval)
793        self.zoom_factor = clip(self.zoom_slider.minimum(),
794                                self.zoom_slider.maximum(),
795                                self.zoom_factor + 1)
796        self.__update_font_scale()
797
798    def __zoom_out(self):
799        def clip(minval, maxval, val):
800            return min(max(val, minval), maxval)
801        self.zoom_factor = clip(self.zoom_slider.minimum(),
802                                self.zoom_slider.maximum(),
803                                self.zoom_factor - 1)
804        self.__update_font_scale()
805
806    def __zoom_reset(self):
807        self.zoom_factor = 0
808        self.__update_font_scale()
809
810    def __layout_main_graphics(self, width=-1):
811        if width < 0:
812            # Preserve current width.
813            width = self._main_graphics.size().width()
814        preferred = self._main_graphics.effectiveSizeHint(
815            Qt.PreferredSize, constraint=QSizeF(width, -1))
816        self._main_graphics.resize(QSizeF(width, preferred.height()))
817        mw = self._main_graphics.minimumWidth() + 4
818        self.view.setMinimumWidth(mw + self.view.verticalScrollBar().width())
819
820    def __update_font_scale(self):
821        font = self.scene.font()
822        factor = (1.25 ** self.zoom_factor)
823        font = qfont_scaled(font, factor)
824        self._main_graphics.setFont(font)
825
826    def send_report(self):
827        annot = self.label_cb.currentText()
828        if isinstance(self.annotation, str):
829            annot = annot.lower()
830        if self.selection_method == 0:
831            sel = "manual"
832        elif self.selection_method == 1:
833            sel = "at {:.1f} of height".format(self.cut_ratio)
834        else:
835            sel = "top {} clusters".format(self.top_n)
836        self.report_items((
837            ("Linkage", LINKAGE[self.linkage].lower()),
838            ("Annotation", annot),
839            ("Prunning",
840             self.pruning != 0 and "{} levels".format(self.max_depth)),
841            ("Selection", sel),
842        ))
843        self.report_plot()
844
845
846def qfont_scaled(font, factor):
847    scaled = QFont(font)
848    if font.pointSizeF() != -1:
849        scaled.setPointSizeF(font.pointSizeF() * factor)
850    elif font.pixelSize() != -1:
851        scaled.setPixelSize(int(font.pixelSize() * factor))
852    return scaled
853
854
855class AxisItem(pg.AxisItem):
856    mousePressed = Signal(QPointF, Qt.MouseButton)
857    mouseMoved = Signal(QPointF, Qt.MouseButtons)
858    mouseReleased = Signal(QPointF, Qt.MouseButton)
859
860    #: \reimp
861    def wheelEvent(self, event):
862        event.ignore()  # ignore event to propagate to the view -> scroll
863
864    def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
865        self.mousePressed.emit(event.pos(), event.button())
866        super().mousePressEvent(event)
867        event.accept()
868
869    def mouseMoveEvent(self, event):
870        self.mouseMoved.emit(event.pos(), event.buttons())
871        super().mouseMoveEvent(event)
872        event.accept()
873
874    def mouseReleaseEvent(self, event):
875        self.mouseReleased.emit(event.pos(), event.button())
876        super().mouseReleaseEvent(event)
877        event.accept()
878
879
880class SliderLine(QGraphicsObject):
881    """A movable slider line."""
882    valueChanged = Signal(float)
883
884    linePressed = Signal()
885    lineMoved = Signal()
886    lineReleased = Signal()
887    rangeChanged = Signal(float, float)
888
889    def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0,
890                 length=10.0, **kwargs):
891        self._orientation = orientation
892        self._value = value
893        self._length = length
894        self._min = 0.0
895        self._max = 1.0
896        self._line = QLineF()  # type: Optional[QLineF]
897        self._pen = QPen()
898        super().__init__(parent, **kwargs)
899
900        self.setAcceptedMouseButtons(Qt.LeftButton)
901        self.setPen(make_pen(brush=QColor(50, 50, 50), width=1, cosmetic=False,
902                             style=Qt.DashLine))
903
904        if self._orientation == Qt.Vertical:
905            self.setCursor(Qt.SizeVerCursor)
906        else:
907            self.setCursor(Qt.SizeHorCursor)
908
909    def setPen(self, pen: Union[QPen, Qt.GlobalColor, Qt.PenStyle]) -> None:
910        pen = QPen(pen)
911        if self._pen != pen:
912            self.prepareGeometryChange()
913            self._pen = pen
914            self._line = None
915            self.update()
916
917    def pen(self) -> QPen:
918        return QPen(self._pen)
919
920    def setValue(self, value: float):
921        value = min(max(value, self._min), self._max)
922
923        if self._value != value:
924            self.prepareGeometryChange()
925            self._value = value
926            self._line = None
927            self.valueChanged.emit(value)
928
929    def value(self) -> float:
930        return self._value
931
932    def setRange(self, minval: float, maxval: float) -> None:
933        maxval = max(minval, maxval)
934        if minval != self._min or maxval != self._max:
935            self._min = minval
936            self._max = maxval
937            self.rangeChanged.emit(minval, maxval)
938            self.setValue(self._value)
939
940    def setLength(self, length: float):
941        if self._length != length:
942            self.prepareGeometryChange()
943            self._length = length
944            self._line = None
945
946    def length(self) -> float:
947        return self._length
948
949    def setOrientation(self, orientation: Qt.Orientation):
950        if self._orientation != orientation:
951            self.prepareGeometryChange()
952            self._orientation = orientation
953            self._line = None
954            if self._orientation == Qt.Vertical:
955                self.setCursor(Qt.SizeVerCursor)
956            else:
957                self.setCursor(Qt.SizeHorCursor)
958
959    def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
960        event.accept()
961        self.linePressed.emit()
962
963    def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
964        pos = event.pos()
965        if self._orientation == Qt.Vertical:
966            self.setValue(pos.y())
967        else:
968            self.setValue(pos.x())
969        self.lineMoved.emit()
970        event.accept()
971
972    def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
973        if self._orientation == Qt.Vertical:
974            self.setValue(event.pos().y())
975        else:
976            self.setValue(event.pos().x())
977        self.lineReleased.emit()
978        event.accept()
979
980    def boundingRect(self) -> QRectF:
981        if self._line is None:
982            if self._orientation == Qt.Vertical:
983                self._line = QLineF(0, self._value, self._length, self._value)
984            else:
985                self._line = QLineF(self._value, 0, self._value, self._length)
986        r = QRectF(self._line.p1(), self._line.p2())
987        penw = self.pen().width()
988        return r.adjusted(-penw, -penw, penw, penw)
989
990    def paint(self, painter, *args):
991        if self._line is None:
992            self.boundingRect()
993
994        painter.save()
995        painter.setPen(self.pen())
996        painter.drawLine(self._line)
997        painter.restore()
998
999
1000def clusters_at_height(root, height):
1001    """Return a list of clusters by cutting the clustering at `height`.
1002    """
1003    lower = set()
1004    cluster_list = []
1005    for cl in preorder(root):
1006        if cl in lower:
1007            continue
1008        if cl.value.height < height:
1009            cluster_list.append(cl)
1010            lower.update(preorder(cl))
1011    return cluster_list
1012
1013
1014if __name__ == "__main__":  # pragma: no cover
1015    from Orange import distance
1016    data = Orange.data.Table("iris")
1017    matrix = distance.Euclidean(distance._preprocess(data))
1018    WidgetPreview(OWHierarchicalClustering).run(matrix)
1019