Repository: biolab/orange3

Issue:
Nomogram: visualization off
**Describe the bug**
Zero probability should be aligned in Nomogram. It is not.

**To Reproduce**
Steps to reproduce the behavior:
[telco.ows.zip](https://github.com/biolab/orange3/files/3851148/telco.ows.zip)

**Orange version:**
3.24.dev

**Expected behavior**
Zero cutoff aligned and the labels are visible or elided.

**Screenshots**
<img width="924" alt="viz-bug" src="https://user-images.githubusercontent.com/12524972/68938600-f97a7200-079e-11ea-948f-12a1afea90c1.png">


**Operating system:**
OSX



Discussion:

Resolving patch:
diff --git a/Orange/widgets/visualize/ownomogram.py b/Orange/widgets/visualize/ownomogram.py
--- a/Orange/widgets/visualize/ownomogram.py
+++ b/Orange/widgets/visualize/ownomogram.py
@@ -1,4 +1,7 @@
 import time
+from itertools import chain
+from typing import List
+from functools import singledispatch
 from enum import IntEnum
 from collections import OrderedDict
 
@@ -13,7 +16,7 @@
 from AnyQt.QtGui import QColor, QPainter, QFont, QPen, QBrush
 from AnyQt.QtCore import Qt, QRectF, QSize
 
-from Orange.data import Table, Domain
+from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable
 from Orange.statistics.util import nanmin, nanmax, nanmean, unique
 from Orange.classification import Model
 from Orange.classification.naive_bayes import NaiveBayesModel
@@ -454,9 +457,10 @@ class DiscreteFeatureItem(RulerItem):
     bold_label = False
     DOT_ITEM_CLS = DiscreteMovableDotItem
 
-    def __init__(self, name, labels, values, scale, name_offset, offset):
+    def __init__(self, name, variable, values, scale, name_offset, offset):
+        labels = _get_labels(variable)
         indices = np.argsort(values)
-        labels, values = np.array(labels)[indices], values[indices]
+        labels, values = labels[indices], values[indices]
         super().__init__(name, values, scale, name_offset, offset, labels)
         self.dot.tooltip_labels = labels
         self.dot.tooltip_values = values
@@ -469,15 +473,31 @@ class ContinuousFeatureItem(RulerItem):
     bold_label = False
     DOT_ITEM_CLS = ContinuousMovableDotItem
 
-    def __init__(self, name, data_extremes, values, scale, name_offset, offset):
-        diff_ = np.nan_to_num(values[-1] - values[0])
-        k = (data_extremes[1] - data_extremes[0]) / diff_ if diff_ else 0
-        labels = [str(np.round(v * k + data_extremes[0], 1)) for v in values]
+    def __init__(self, name, variable, data_extremes, values, scale,
+                 name_offset, offset):
+        labels = _get_labels(variable, data_extremes, values)
         super().__init__(name, values, scale, name_offset, offset, labels)
         self.dot.tooltip_labels = labels
         self.dot.tooltip_values = values
 
 
+@singledispatch
+def _get_labels(*_):
+    return []
+
+
+@_get_labels.register(DiscreteVariable)
+def _(var: DiscreteVariable, *_):
+    return np.array(var.values)
+
+
+@_get_labels.register(ContinuousVariable)
+def _(_: ContinuousVariable, data_extremes: List, values: np.ndarray, *__):
+    diff_ = np.nan_to_num(values[-1] - values[0])
+    k = (data_extremes[1] - data_extremes[0]) / diff_ if diff_ else 0
+    return [str(np.round(v * k + data_extremes[0], 1)) for v in values]
+
+
 class ContinuousFeature2DItem(QGraphicsWidget):
     tick_height = 6
     tick_width = 2
@@ -485,7 +505,8 @@ class ContinuousFeature2DItem(QGraphicsWidget):
     y_diff = 80
     n_tck = 4
 
-    def __init__(self, name, data_extremes, values, scale, name_offset, offset):
+    def __init__(self, name, _, data_extremes, values, scale, name_offset,
+                 offset):
         super().__init__()
         data_start, data_stop = data_extremes[0], data_extremes[1]
         labels = [str(np.round(data_start + (data_stop - data_start) * i /
@@ -912,6 +933,34 @@ def update_scene(self):
         self.top_view.setSceneRect(rect.x(), rect.y() + 3, rect.width() - 10, 20)
         self.bottom_view.setSceneRect(rect.x(), rect.height() - 110, rect.width() - 10, 30)
 
+    @staticmethod
+    def _adjust_scale(attributes, points, max_width, diff, attr_inds,
+                      log_reg_cont_data_extremes, cls_index):
+        if not diff:
+            return max_width
+
+        def offset(name, point):
+            text_ = QGraphicsTextItem(name).boundingRect()
+            return scale * point + text_.width() / 2
+
+        lr = log_reg_cont_data_extremes
+        scale = max_width / diff
+        names = list(chain.from_iterable(
+            [_get_labels(a, lr and lr[i] and lr[i][0] and lr[i][cls_index],
+                         OWNomogram.get_ruler_values(p.min(), p.max(),
+                                                     scale * p.ptp(), False))
+             for i, a, p in zip(attr_inds, attributes, points)]))
+        points = list(chain.from_iterable(points))
+
+        old_scale = scale + 1
+        while old_scale > scale:
+            old_scale = scale
+            offsets = [offset(n, p) for n, p in zip(names, points)]
+            most_right_name = names[np.argmax(offsets)]
+            text = QGraphicsTextItem(most_right_name).boundingRect()
+            scale = (max_width - text.width() / 2) / diff
+        return scale
+
     def create_main_nomogram(self, attributes, attr_inds, name_items, points,
                              max_width, point_text, name_offset):
         cls_index = self.target_class_index
@@ -920,7 +969,10 @@ def create_main_nomogram(self, attributes, attr_inds, name_items, points,
         values = self.get_ruler_values(min_p, max_p, max_width)
         min_p, max_p = min(values), max(values)
         diff_ = np.nan_to_num(max_p - min_p)
-        scale_x = max_width / diff_ if diff_ else max_width
+        scale_x = self._adjust_scale(
+            attributes, points, max_width, diff_, attr_inds,
+            self.log_reg_cont_data_extremes, cls_index
+        )
 
         nomogram_header = NomogramItem()
         point_item = RulerItem(point_text, values, scale_x, name_offset,
@@ -934,16 +986,17 @@ def create_main_nomogram(self, attributes, attr_inds, name_items, points,
 
         feature_items = [
             DiscreteFeatureItem(
-                name_item, attr.values, point,
+                name_item, attr, point,
                 scale_x, name_offset, - scale_x * min_p)
             if attr.is_discrete else
             cont_feature_item_class(
-                name_item, self.log_reg_cont_data_extremes[i][cls_index],
+                name_item, attr, self.log_reg_cont_data_extremes[i][cls_index],
                 self.get_ruler_values(
                     point.min(), point.max(),
                     scale_x * point.ptp(), False),
                 scale_x, name_offset, - scale_x * min_p)
-            for i, attr, name_item, point in zip(attr_inds, attributes, name_items, points)]
+            for i, attr, name_item, point in
+            zip(attr_inds, attributes, name_items, points)]
 
         self.nomogram_main.add_items(feature_items)
         self.feature_items = OrderedDict(sorted(zip(attr_inds, feature_items)))


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_ownomogram.py b/Orange/widgets/visualize/tests/test_ownomogram.py
--- a/Orange/widgets/visualize/tests/test_ownomogram.py
+++ b/Orange/widgets/visualize/tests/test_ownomogram.py
@@ -1,6 +1,7 @@
 # Test methods with long descriptive names can omit docstrings
-# pylint: disable=missing-docstring
+# pylint: disable=missing-docstring,protected-access
 import unittest
+from unittest.mock import Mock, patch
 
 import numpy as np
 
@@ -250,6 +251,33 @@ def test_reset_settings(self):
         self.widget.reset_settings()
         self.assertEqual(10, self.widget.n_attributes)
 
+    @patch("Orange.widgets.visualize.ownomogram.QGraphicsTextItem")
+    def test_adjust_scale(self, mocked_item: Mock):
+        def mocked_width():
+            nonlocal ind
+            ind += 1
+            most_right = {4: 30, 9: 59, 14: 59}
+            return [2, 30, 59, 2, most_right.get(ind)][ind % 5]
+
+        ind = -1
+        mocked_item().boundingRect().width.side_effect = mocked_width
+        attrs = [DiscreteVariable("var1", values=["foo1", "foo2"]),
+                 DiscreteVariable("var2", values=["foo3", "foo4"])]
+        points = [np.array([0, 1.8]), np.array([1.5, 2.0])]
+        diff = np.max(points) - np.min(points)
+        # foo3 eventually overcomes foo2, while the scale is getting smaller
+        #
+        #  0                        1              1.5         1.8     2
+        # foo1                                          _______foo2_______
+        #                               __________foo3__________      foo4
+        self.widget._adjust_scale(attrs, points, 100, diff, [0, 1], [], 0)
+        # most left text at 1. iteration
+        self.assertEqual(mocked_item.call_args_list[5][0][0], "foo2")
+        # most left text at 2. iteration
+        self.assertEqual(mocked_item.call_args_list[10][0][0], "foo3")
+        # most left text at 3. iteration is the same -> stop
+        self.assertEqual(mocked_item.call_args_list[15][0][0], "foo3")
+
 
 if __name__ == "__main__":
     unittest.main()