Repository: biolab/orange3

Issue:
Legend and Size setting: don't show more than 5 values
##### Issue
Visualizations enable displaying shapes of points, which are derived from categorical variables. If a categorical variable has many values, strange things begin to happen --> see how shapes represent different values in each plot?

##### Scatter Plot
<img width="1068" alt="Screen Shot 2019-04-12 at 12 47 25" src="https://user-images.githubusercontent.com/12524972/56032102-8c42c200-5d21-11e9-8f6b-64f113d50f24.png">

##### Linear Projection
<img width="832" alt="Screen Shot 2019-04-12 at 12 47 17" src="https://user-images.githubusercontent.com/12524972/56032116-9664c080-5d21-11e9-9e17-28e344b6d250.png">

##### Geo Map
<img width="1133" alt="Screen Shot 2019-04-12 at 12 35 37" src="https://user-images.githubusercontent.com/12524972/56032131-9d8bce80-5d21-11e9-8b6c-10a58d240b86.png">

In three different widgets we show three different things. This is confusing and, quite frankly, ugly.

##### Proposed solution
Do not allow selecting a categorical attribute with more than 5 values. Edit Domain now has the option to merge several discrete values into one. ⭐️ 
Another argument for limiting the upper bound to 5 is that one cannot keep 9 shapes in mind while inspecting a plot. Even 5 is a lot.





Discussion:
Filtering categorical attributes by number of values would be hacky and, moreover, confusing to the user who wouldn't know why some values are missing. I'd prefer merging all but the four most common values into a single one.

All visualizations that use the common projection base class show shapes in the same way -- everything is handled by the base class, not the widget itself. I suppose that the difference arises because some values (value '6'?) are missing.

As for Geo map, it uses a different subclass. Thus if this is a bug, it's not a bug in Orange widgets but in the geo add-on.
We will decrease the number of different symbols to 6 (or 6 + other) and reconsider the choice of symbols.

Resolving patch:
diff --git a/Orange/widgets/visualize/owscatterplotgraph.py b/Orange/widgets/visualize/owscatterplotgraph.py
--- a/Orange/widgets/visualize/owscatterplotgraph.py
+++ b/Orange/widgets/visualize/owscatterplotgraph.py
@@ -371,7 +371,7 @@ def get_size_data(self):
 
     resolution = 256
 
-    CurveSymbols = np.array("o x t + d s t2 t3 p h star ?".split())
+    CurveSymbols = np.array("o x t + d star ?".split())
     MinShapeSize = 6
     DarkerValue = 120
     UnknownColor = (168, 50, 168)
diff --git a/Orange/widgets/visualize/utils/widget.py b/Orange/widgets/visualize/utils/widget.py
--- a/Orange/widgets/visualize/utils/widget.py
+++ b/Orange/widgets/visualize/utils/widget.py
@@ -28,7 +28,12 @@
 from Orange.widgets.visualize.utils.component import OWGraphWithAnchors
 from Orange.widgets.widget import OWWidget, Input, Output, Msg
 
-MAX_CATEGORIES = 11  # maximum number of colors or shapes (including Other)
+# maximum number of colors (including Other)
+MAX_COLORS = 11
+
+# maximum number of shapes (including Other)
+MAX_SHAPES = len(OWScatterPlotBase.CurveSymbols) - 1
+
 MAX_POINTS_IN_TOOLTIP = 5
 
 
@@ -99,7 +104,7 @@ def get_subset_mask(self):
         return None
 
     def get_column(self, attr, filter_valid=True,
-                   merge_infrequent=False, return_labels=False):
+                   max_categories=None, return_labels=False):
         """
         Retrieve the data from the given column in the data table
 
@@ -109,10 +114,10 @@ def get_column(self, attr, filter_valid=True,
           actually primitive,
         - filters out invalid data (if `filter_valid` is `True`),
         - merges infrequent (discrete) values into a single value
-          (if `merge_infrequent` is `True`).
+          (if `max_categories` is set).
 
         Tha latter feature is used for shapes and labels, where only a
-        set number (`MAX`) of different values is shown, and others are
+        specified number of different values is shown, and others are
         merged into category 'Other'. In this case, the method may return
         either the data (e.g. color indices, shape indices) or the list
         of retained values, followed by `['Other']`.
@@ -120,7 +125,7 @@ def get_column(self, attr, filter_valid=True,
         Args:
             attr (:obj:~Orange.data.Variable): the column to extract
             filter_valid (bool): filter out invalid data (default: `True`)
-            merge_infrequent (bool): merge infrequent values (default: `False`);
+            max_categories (int): merge infrequent values (default: `None`);
                 ignored for non-discrete attributes
             return_labels (bool): return a list of labels instead of data
                 (default: `False`)
@@ -131,9 +136,9 @@ def get_column(self, attr, filter_valid=True,
         if attr is None:
             return None
 
-        needs_merging = \
-            attr.is_discrete \
-            and merge_infrequent and len(attr.values) >= MAX_CATEGORIES
+        needs_merging = attr.is_discrete \
+                        and max_categories is not None \
+                        and len(attr.values) >= max_categories
         if return_labels and not needs_merging:
             assert attr.is_discrete
             return attr.values
@@ -148,7 +153,7 @@ def get_column(self, attr, filter_valid=True,
 
         dist = bincount(all_data, max_val=len(attr.values) - 1)[0]
         infrequent = np.zeros(len(attr.values), dtype=bool)
-        infrequent[np.argsort(dist)[:-(MAX_CATEGORIES-1)]] = True
+        infrequent[np.argsort(dist)[:-(max_categories-1)]] = True
         if return_labels:
             return [value for value, infreq in zip(attr.values, infrequent)
                     if not infreq] + ["Other"]
@@ -157,7 +162,7 @@ def get_column(self, attr, filter_valid=True,
             freq_vals = [i for i, f in enumerate(infrequent) if not f]
             for i, infreq in enumerate(infrequent):
                 if infreq:
-                    result[all_data == i] = MAX_CATEGORIES - 1
+                    result[all_data == i] = max_categories - 1
                 else:
                     result[all_data == i] = freq_vals.index(i)
             return result
@@ -187,7 +192,7 @@ def sizes_changed(self):
     # Colors
     def get_color_data(self):
         """Return the column corresponding to color data"""
-        return self.get_column(self.attr_color, merge_infrequent=True)
+        return self.get_column(self.attr_color, max_categories=MAX_COLORS)
 
     def get_color_labels(self):
         """
@@ -200,7 +205,7 @@ def get_color_labels(self):
             return None
         if not self.attr_color.is_discrete:
             return self.attr_color.str_val
-        return self.get_column(self.attr_color, merge_infrequent=True,
+        return self.get_column(self.attr_color, max_categories=MAX_COLORS,
                                return_labels=True)
 
     def is_continuous_color(self):
@@ -224,8 +229,8 @@ def get_palette(self):
         colors = self.attr_color.colors
         if self.attr_color.is_discrete:
             return ColorPaletteGenerator(
-                number_of_colors=min(len(colors), MAX_CATEGORIES),
-                rgb_colors=colors if len(colors) <= MAX_CATEGORIES
+                number_of_colors=min(len(colors), MAX_COLORS),
+                rgb_colors=colors if len(colors) <= MAX_COLORS
                 else DefaultRGBColors)
         else:
             return ContinuousPaletteGenerator(*colors)
@@ -266,10 +271,10 @@ def get_shape_data(self):
         Returns:
             (list of str): labels
         """
-        return self.get_column(self.attr_shape, merge_infrequent=True)
+        return self.get_column(self.attr_shape, max_categories=MAX_SHAPES)
 
     def get_shape_labels(self):
-        return self.get_column(self.attr_shape, merge_infrequent=True,
+        return self.get_column(self.attr_shape, max_categories=MAX_SHAPES,
                                return_labels=True)
 
     def impute_shapes(self, shape_data, default_symbol):


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owprojectionwidget.py b/Orange/widgets/visualize/tests/test_owprojectionwidget.py
--- a/Orange/widgets/visualize/tests/test_owprojectionwidget.py
+++ b/Orange/widgets/visualize/tests/test_owprojectionwidget.py
@@ -1,7 +1,7 @@
 # Test methods with long descriptive names can omit docstrings
 # pylint: disable=missing-docstring
 import unittest
-from unittest.mock import patch, Mock
+from unittest.mock import Mock
 
 import numpy as np
 
@@ -61,14 +61,13 @@ def test_get_column(self):
         self.assertEqual(get_column(disc3, return_labels=True), disc3.values)
         with self.assertRaises(AssertionError):
             get_column(cont, return_labels=True)
-            get_column(cont, return_labels=True, merge_infrequent=True)
-            get_column(cont, merge_infrequent=True)
+        with self.assertRaises(AssertionError):
+            get_column(cont, return_labels=True, max_categories=4)
         with self.assertRaises(AssertionError):
             get_column(string, return_labels=True)
-            get_column(string, return_labels=True, merge_infrequent=True)
-            get_column(string, merge_infrequent=True)
+        with self.assertRaises(AssertionError):
+            get_column(string, return_labels=True, max_categories=4)
 
-    @patch("Orange.widgets.visualize.utils.widget.MAX_CATEGORIES", 4)
     def test_get_column_merge_infrequent(self):
         widget = self.widget
         get_column = widget.get_column
@@ -88,15 +87,15 @@ def test_get_column_merge_infrequent(self):
         self.assertEqual(get_column(disc2, return_labels=True), disc2.values)
 
         np.testing.assert_almost_equal(
-            get_column(disc, merge_infrequent=True),
+            get_column(disc, max_categories=4),
             [1, 1, 1, 2, 3, 1, 1, 2, 3, 2, 2, 0, 0, 0, 3, 2, 3])
         self.assertEqual(
-            get_column(disc, merge_infrequent=True, return_labels=True),
+            get_column(disc, max_categories=4, return_labels=True),
             [disc.values[0], disc.values[1], disc.values[5], "Other"])
         np.testing.assert_almost_equal(
-            get_column(disc2, merge_infrequent=True), y)
+            get_column(disc2, max_categories=4), y)
         self.assertEqual(
-            get_column(disc2, return_labels=True, merge_infrequent=True),
+            get_column(disc2, return_labels=True, max_categories=4),
             disc2.values)
 
         # Test that get_columns modify a copy of the data and not the data
diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py
--- a/Orange/widgets/visualize/tests/test_owscatterplot.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplot.py
@@ -16,7 +16,7 @@
 from Orange.widgets.utils.colorpalette import DefaultRGBColors
 from Orange.widgets.visualize.owscatterplot import (
     OWScatterPlot, ScatterPlotVizRank, OWScatterPlotGraph)
-from Orange.widgets.visualize.utils.widget import MAX_CATEGORIES
+from Orange.widgets.visualize.utils.widget import MAX_COLORS
 from Orange.widgets.widget import AttributeList
 
 
@@ -585,11 +585,11 @@ def assert_equal(data, max):
             pen_data, _ = self.widget.graph.get_colors()
             self.assertEqual(max, len(np.unique([id(p) for p in pen_data])), )
 
-        assert_equal(prepare_data(), MAX_CATEGORIES)
+        assert_equal(prepare_data(), MAX_COLORS)
         # data with nan value
         data = prepare_data()
         data.Y[42] = np.nan
-        assert_equal(data, MAX_CATEGORIES + 1)
+        assert_equal(data, MAX_COLORS + 1)
 
     def test_change_data(self):
         self.send_signal(self.widget.Inputs.data, self.data)
diff --git a/Orange/widgets/visualize/tests/test_owscatterplotbase.py b/Orange/widgets/visualize/tests/test_owscatterplotbase.py
--- a/Orange/widgets/visualize/tests/test_owscatterplotbase.py
+++ b/Orange/widgets/visualize/tests/test_owscatterplotbase.py
@@ -163,7 +163,7 @@ def test_sampling(self):
                         np.arange(0, 30, 3, dtype=float))
         d = np.arange(10, dtype=float)
         master.get_size_data = lambda: d
-        master.get_shape_data = lambda: d
+        master.get_shape_data = lambda: d % 5 if d is not None else None
         master.get_color_data = lambda: d
         master.get_label_data = lambda: \
             np.array([str(x) for x in d], dtype=object)
@@ -187,7 +187,7 @@ def test_sampling(self):
             (x[2] - x[1]) / (x[1] - x[0]))
         self.assertEqual(
             list(data["symbol"]),
-            [graph.CurveSymbols[int(xi)] for xi in x])
+            [graph.CurveSymbols[int(xi) % 5] for xi in x])
         self.assertEqual(
             [pen.color().hue() for pen in data["pen"]],
             [graph.palette[xi].hue() for xi in x])
@@ -207,7 +207,7 @@ def test_sampling(self):
         np.testing.assert_almost_equal(s, precise_s, decimal=0)
         self.assertEqual(
             list(data["symbol"]),
-            [graph.CurveSymbols[int(xi)] for xi in x])
+            [graph.CurveSymbols[int(xi) % 5] for xi in x])
         self.assertEqual(
             [pen.color().hue() for pen in data["pen"]],
             [graph.palette[xi].hue() for xi in x])
@@ -224,7 +224,7 @@ def test_sampling(self):
         np.testing.assert_almost_equal(y, xy[1])
         self.assertEqual(
             list(data["symbol"]),
-            [graph.CurveSymbols[int(xi)] for xi in d])
+            [graph.CurveSymbols[int(xi) % 5] for xi in d])
         self.assertEqual(
             [pen.color().hue() for pen in data["pen"]],
             [graph.palette[xi].hue() for xi in d])
@@ -245,7 +245,7 @@ def test_sampling(self):
             (x[2] - x[1]) / (x[1] - x[0]))
         self.assertEqual(
             list(data["symbol"]),
-            [graph.CurveSymbols[int(xi)] for xi in x])
+            [graph.CurveSymbols[int(xi) % 5] for xi in x])
         self.assertEqual(
             [pen.color().hue() for pen in data["pen"]],
             [graph.palette[xi].hue() for xi in x])