Repository: biolab/orange3

Issue:
OWRadViz: legible axis labels
Follow up on #2110.

Current widget status. Similar to #3743. Limit the number of features displayed to 20. If features names are long, elide them after 20 chars.
<img width="869" alt="Screen Shot 2019-04-12 at 11 26 33" src="https://user-images.githubusercontent.com/12524972/56027315-1dac3700-5d16-11e9-9db9-1dc1d7dced5e.png">


Discussion:
Propose an interface for limiting the number of shown attributes. If the 21st is added, the bottom one disappears?
This widget requires some love. In the above example, Japan is written over the point that is actually Kenya. Labels for points on the left should be on the left side. The labels on the bottom overlap... @robertcv?

Resolving patch:
diff --git a/Orange/widgets/utils/plot/owplotgui.py b/Orange/widgets/utils/plot/owplotgui.py
--- a/Orange/widgets/utils/plot/owplotgui.py
+++ b/Orange/widgets/utils/plot/owplotgui.py
@@ -58,13 +58,20 @@ class VariableSelectionModel(VariableListModel):
     SortRole = next(OrangeUserRole)
     selection_changed = pyqtSignal()
 
-    def __init__(self, selected_vars):
+    def __init__(self, selected_vars, max_vars=None):
         super().__init__(enable_dnd=True)
         self.selected_vars = selected_vars
+        self.max_vars = max_vars
 
     def is_selected(self, index):
         return self[index.row()] in self.selected_vars
 
+    def is_full(self):
+        if self.max_vars is None:
+            return False
+        else:
+            return len(self.selected_vars) >= self.max_vars
+
     def data(self, index, role):
         if role == self.IsSelected:
             return self.is_selected(index)
@@ -84,7 +91,7 @@ def toggle_item(self, index):
         var = self[index.row()]
         if var in self.selected_vars:
             self.selected_vars.remove(var)
-        else:
+        elif not self.is_full():
             self.selected_vars.append(var)
         self.selection_changed.emit()
 
@@ -131,17 +138,19 @@ def paint(self, painter, option, index):
         rect = QRect(option.rect)
 
         is_selected = index.data(VariableSelectionModel.IsSelected)
+        full_selection = index.model().sourceModel().is_full()
         if option.state & QStyle.State_MouseOver:
-            txt = [" Add ", " Remove "][is_selected]
-            txtw = painter.fontMetrics().width(txt)
-            painter.save()
-            painter.setPen(Qt.NoPen)
-            painter.setBrush(QColor("#ccc"))
-            brect = QRect(rect.x() + rect.width() - 8 - txtw, rect.y(),
-                          txtw, rect.height())
-            painter.drawRoundedRect(brect, 4, 4)
-            painter.restore()
-            painter.drawText(brect, Qt.AlignCenter, txt)
+            if not full_selection or (full_selection and is_selected):
+                txt = [" Add ", " Remove "][is_selected]
+                txtw = painter.fontMetrics().width(txt)
+                painter.save()
+                painter.setPen(Qt.NoPen)
+                painter.setBrush(QColor("#ccc"))
+                brect = QRect(rect.x() + rect.width() - 8 - txtw, rect.y(),
+                              txtw, rect.height())
+                painter.drawRoundedRect(brect, 4, 4)
+                painter.restore()
+                painter.drawText(brect, Qt.AlignCenter, txt)
 
         painter.save()
         double_pen = painter.pen()
diff --git a/Orange/widgets/visualize/owradviz.py b/Orange/widgets/visualize/owradviz.py
--- a/Orange/widgets/visualize/owradviz.py
+++ b/Orange/widgets/visualize/owradviz.py
@@ -205,17 +205,21 @@ def stopped(self):
         self.n_attrs_spin.setDisabled(False)
 
 
+MAX_LABEL_LEN = 16
+
+
 class OWRadvizGraph(OWGraphWithAnchors):
     def __init__(self, scatter_widget, parent):
         super().__init__(scatter_widget, parent)
         self.anchors_scatter_item = None
+        self.padding = 0.025
 
     def clear(self):
         super().clear()
         self.anchors_scatter_item = None
 
     def set_view_box_range(self):
-        self.view_box.setRange(QRectF(-1.2, -1.05, 2.4, 2.1), padding=0.025)
+        self.view_box.setRange(QRectF(-1, -1, 2, 2), padding=self.padding)
 
     def closest_draggable_item(self, pos):
         points, _ = self.master.get_anchors()
@@ -231,18 +235,55 @@ def update_anchors(self):
         points, labels = self.master.get_anchors()
         if points is None:
             return
-        if self.anchor_items is None:
-            self.anchor_items = []
-            for point, label in zip(points, labels):
-                anchor = TextItem()
-                anchor.setText(label)
-                anchor.setColor(QColor(0, 0, 0))
-                anchor.setPos(*point)
-                self.plot_widget.addItem(anchor)
-                self.anchor_items.append(anchor)
-        else:
-            for anchor, point in zip(self.anchor_items, points):
-                anchor.setPos(*point)
+        if self.anchor_items is not None:
+            for anchor in self.anchor_items:
+                self.plot_widget.removeItem(anchor)
+
+        self.anchor_items = []
+        label_len = 1
+        for point, label in zip(points, labels):
+            anchor = TextItem()
+            anchor.textItem.setToolTip(f"<b>{label}</b>")
+
+            if len(label) > MAX_LABEL_LEN:
+                i = label.rfind(" ", 0, MAX_LABEL_LEN)
+                if i != -1:
+                    first_row = label[:i] + "\n"
+                    second_row = label[i + 1:]
+                    if len(second_row) > MAX_LABEL_LEN:
+                        j = second_row.rfind(" ", 0, MAX_LABEL_LEN)
+                        if j != -1:
+                            second_row = second_row[:j + 1] + "..."
+                        else:
+                            second_row = second_row[:MAX_LABEL_LEN - 3] + "..."
+                    label = first_row + second_row
+                else:
+                    label = label[:MAX_LABEL_LEN - 3] + "..."
+
+            anchor.setText(label)
+            label_len = min(MAX_LABEL_LEN, len(label))
+            anchor.setColor(QColor(0, 0, 0))
+
+            x, y = point
+            angle = np.rad2deg(np.arctan2(y, x))
+            anchor.setPos(x * 1.025, y * 1.025)
+
+            if abs(angle) < 90:
+                anchor.setAngle(angle)
+                anchor.setAnchor((0, 0.5))
+            else:
+                anchor.setAngle(angle + 180)
+                anchor.setAnchor((1, 0.5))
+
+                anchor.textItem.setTextWidth(anchor.textItem.boundingRect().width())
+                option = anchor.textItem.document().defaultTextOption()
+                option.setAlignment(Qt.AlignRight)
+                anchor.textItem.document().setDefaultTextOption(option)
+
+            self.plot_widget.addItem(anchor)
+            self.anchor_items.append(anchor)
+
+        self.padding = label_len * 0.0175
         self._update_anchors_scatter_item(points)
 
     def _update_anchors_scatter_item(self, points):
@@ -284,9 +325,11 @@ class Warning(OWAnchorProjectionWidget.Warning):
         invalid_embedding = widget.Msg("No projection for selected features")
         removed_vars = widget.Msg("Categorical variables with more than"
                                   " two values are not shown.")
+        max_vars_selected = widget.Msg("Maximum number of selected variables reached.")
 
     def _add_controls(self):
-        self.model_selected = VariableSelectionModel(self.selected_vars)
+        self.model_selected = VariableSelectionModel(self.selected_vars,
+                                                     max_vars=20)
         variables_selection(self.controlArea, self, self.model_selected)
         self.model_selected.selection_changed.connect(
             self.__model_selected_changed)
@@ -318,6 +361,10 @@ def vizrank_set_attrs(self, *attrs):
         self.model_selected.selection_changed.emit()
 
     def __model_selected_changed(self):
+        if self.model_selected.is_full():
+            self.Warning.max_vars_selected()
+        else:
+            self.Warning.max_vars_selected.clear()
         self.init_projection()
         self.setup_plot()
         self.commit()


Test patch: