Repository: biolab/orange3

Issue:
Mosaic Display: legend falls out of view
<!--
This is an issue template. Please fill in the relevant details in the
sections below.

Wrap code and verbatim terminal window output into triple backticks, see:
https://help.github.com/articles/basic-writing-and-formatting-syntax/#quoting-code

If you're raising an issue about an add-on (e.g. installed via
Options > Add-ons), raise an issue on the relevant add-on's issue
tracker instead. See: https://github.com/biolab?q=orange3
-->

##### Orange version
<!-- From menu _Help→About→Version_ or code `Orange.version.full_version` -->
3.14.dev

##### Expected behavior
Legend is clearly visible.


##### Actual behavior
When many items in legend, legend falls out of view.


##### Steps to reproduce the behavior
File - Mosaic
data: https://www.kaggle.com/usdot/pipeline-accidents/data
color by: Cause Category

##### Additional info 
While viewing so many values is likely impractical, Mosaic is still one of the nicer visualizations for such purpose.



Discussion:
<img width="1077" alt="screen shot 2018-06-29 at 12 08 24" src="https://user-images.githubusercontent.com/12524972/42087010-27f61040-7b95-11e8-93c8-7c12bc16578e.png">

Legend currently horizontal. Perhaps have it on the right side, where each color is in a new line? Similar to Scatter Plot? Also limit legend display to 10 items, 9 + other. Like in Scatter Plot, so there is consistency.
The legend below wastes less space than one beside the graph -- with everything under the legend being empty.

An alternative suggestion, though I'm not sure I like it: instead of a legend there could be a list box in the control area, in which the user would be able to select (by clicking, which selects/deselects) an element the classes that are actually shown. We may not actually need this functionality, but it serves as a de facto legend which is placed where we have too much space (the control area is mostly empty) instead of in the graph, where it would just add a lot of white space.

If we decide for this, the `send_report` method needs to add a legend after the plot.
I agree with Janez: the legend is currently placed well. The easiest fix might be to elide text.
Yes, it's a good point. Elide or break the legend and place it into a new line where it falls out of view?
I implemented (with a few glitches) wrapping of legend items. However, I don't know how to arrange the items. Currently they are centered

<img width="453" alt="Screenshot 2019-06-07 at 11 25 40" src="https://user-images.githubusercontent.com/2387315/59094858-60098300-8917-11e9-9419-ffddaf17b571.png">

or left-aligned

<img width="446" alt="Screenshot 2019-06-07 at 11 33 43" src="https://user-images.githubusercontent.com/2387315/59095201-2dac5580-8918-11e9-829e-4ca350b9a998.png">

Another option is a grid

<img width="517" alt="Screenshot 2019-06-07 at 11 31 20" src="https://user-images.githubusercontent.com/2387315/59095265-52a0c880-8918-11e9-8825-9ca79d5de3ba.png">

but this would break if one (or more) labels are significantly longer than another.

An option is to use a grid *unless* the longest label is twice the width of the median label, in which case it switches to left-aligned.

Opinions?
Currently = in my current implementation. "Currently" in Orange they are of course in a single line and fall out of view, as the issue describes.
I like either of the first two options. Perhaps solution to long labels is simply elide and tooltip on hover?
Here. When it fits into a single line, it's centered. If it doesn't, it's put into grid. If this grid would have just a single column, it's left-aligned. See the animation.

![mosaic](https://user-images.githubusercontent.com/2387315/59098806-9d730e00-8921-11e9-8a5e-36d96c5a9f73.gif)


Resolving patch:
diff --git a/Orange/widgets/visualize/owmosaic.py b/Orange/widgets/visualize/owmosaic.py
--- a/Orange/widgets/visualize/owmosaic.py
+++ b/Orange/widgets/visualize/owmosaic.py
@@ -1,6 +1,6 @@
 from collections import defaultdict
 from functools import reduce
-from itertools import product, chain
+from itertools import product, chain, repeat
 from math import sqrt, log
 from operator import mul, attrgetter
 
@@ -9,7 +9,8 @@
 from scipy.special import comb
 from AnyQt.QtCore import Qt, QSize, pyqtSignal as Signal
 from AnyQt.QtGui import QColor, QPainter, QPen, QStandardItem
-from AnyQt.QtWidgets import QGraphicsScene, QGraphicsLineItem
+from AnyQt.QtWidgets import (
+    QGraphicsScene, QGraphicsLineItem, QGraphicsItemGroup)
 
 from Orange.data import Table, filter, Variable, Domain
 from Orange.data.sql.table import SqlTable, LARGE_TABLE, DEFAULT_SAMPLE_TIME
@@ -28,6 +29,7 @@
 from Orange.widgets.utils.widgetpreview import WidgetPreview
 from Orange.widgets.visualize.utils import (
     CanvasText, CanvasRectangle, ViewWithPress, VizRankDialog)
+from Orange.widgets.visualize.utils.plotutils import wrap_legend_items
 from Orange.widgets.widget import OWWidget, Msg, Input, Output
 
 
@@ -813,42 +815,29 @@ def line(x1, y1, x2, y2):
                     "{}<hr>Instances: {}<br><br>{}".format(
                         condition, n_actual, text[:-4]))
 
-        def draw_legend(x0_x1, y0_y1):
-            x0, x1 = x0_x1
-            _, y1 = y0_y1
+        def create_legend():
             if self.variable_color is None:
                 names = ["<-8", "-8:-4", "-4:-2", "-2:2", "2:4", "4:8", ">8",
                          "Residuals:"]
                 colors = self.RED_COLORS[::-1] + self.BLUE_COLORS[1:]
+                edges = repeat(Qt.black)
             else:
-                names = get_variable_values_sorted(class_var) + \
-                        [class_var.name + ":"]
-                colors = [QColor(*col) for col in class_var.colors]
-
-            names = [CanvasText(self.canvas, name, alignment=Qt.AlignVCenter)
-                     for name in names]
-            totalwidth = sum(text.boundingRect().width() for text in names)
-
-            # compute the x position of the center of the legend
-            y = y1 + self.ATTR_NAME_OFFSET + self.ATTR_VAL_OFFSET + 35
-            distance = 30
-            startx = (x0 + x1) / 2 - (totalwidth + (len(names)) * distance) / 2
-
-            names[-1].setPos(startx + 15, y)
-            names[-1].show()
-            xoffset = names[-1].boundingRect().width() + distance
+                names = get_variable_values_sorted(class_var)
+                edges = colors = [QColor(*col) for col in class_var.colors]
 
+            items = []
             size = 8
-            for i in range(len(names) - 1):
-                if self.variable_color is None:
-                    edgecolor = Qt.black
-                else:
-                    edgecolor = colors[i]
-
-                CanvasRectangle(self.canvas, startx + xoffset, y - size / 2,
-                                size, size, edgecolor, colors[i])
-                names[i].setPos(startx + xoffset + 10, y)
-                xoffset += distance + names[i].boundingRect().width()
+            for name, color, edgecolor in zip(names, colors, edges):
+                item = QGraphicsItemGroup()
+                item.addToGroup(
+                    CanvasRectangle(None, -size / 2, -size / 2, size, size,
+                                    edgecolor, color))
+                item.addToGroup(
+                    CanvasText(None, name, size, 0, Qt.AlignVCenter))
+                items.append(item)
+            return wrap_legend_items(
+                items, hspacing=20, vspacing=16 + size,
+                max_width=self.canvas_view.width() - xoff)
 
         self.canvas.clear()
         self.areas = []
@@ -896,8 +885,9 @@ def get_max_label_width(attr):
                 maxw = max(int(t.boundingRect().width()), maxw)
             return maxw
 
-        # get the maximum width of rectangle
         xoff = 20
+
+        # get the maximum width of rectangle
         width = 20
         max_ylabel_w1 = max_ylabel_w2 = 0
         if len(attr_list) > 1:
@@ -912,11 +902,14 @@ def get_max_label_width(attr):
                 width += text.boundingRect().height() + \
                     self.ATTR_VAL_OFFSET + max_ylabel_w2 - 10
 
+        legend = create_legend()
+
         # get the maximum height of rectangle
-        height = 100
         yoff = 45
+        legendoff = yoff + self.ATTR_NAME_OFFSET + self.ATTR_VAL_OFFSET + 35
         square_size = min(self.canvas_view.width() - width - 20,
-                          self.canvas_view.height() - height - 20)
+                          self.canvas_view.height() - legendoff
+                          - legend.boundingRect().height())
 
         if square_size < 0:
             return  # canvas is too small to draw rectangles
@@ -937,7 +930,12 @@ def get_max_label_width(attr):
         draw_data(
             attr_list, (xoff, xoff + square_size), (yoff, yoff + square_size),
             0, "", len(attr_list), [], [])
-        draw_legend((xoff, xoff + square_size), (yoff, yoff + square_size))
+
+        self.canvas.addItem(legend)
+        legend.setPos(
+            xoff - legend.boundingRect().x()
+            + max(0, (square_size - legend.boundingRect().width()) / 2),
+            legendoff + square_size)
         self.update_selection_rects()
 
     @classmethod
diff --git a/Orange/widgets/visualize/utils/plotutils.py b/Orange/widgets/visualize/utils/plotutils.py
--- a/Orange/widgets/visualize/utils/plotutils.py
+++ b/Orange/widgets/visualize/utils/plotutils.py
@@ -5,8 +5,8 @@
 )
 from AnyQt.QtGui import QTransform
 from AnyQt.QtWidgets import (
-    QGraphicsLineItem, QGraphicsSceneMouseEvent, QPinchGesture
-)
+    QGraphicsLineItem, QGraphicsSceneMouseEvent, QPinchGesture,
+    QGraphicsItemGroup)
 
 import pyqtgraph as pg
 
@@ -348,3 +348,41 @@ def mouseDragEvent(self, ev, axis=None):
             else:
                 self.moved.emit(self.item_id, pos.x(), pos.y())
             self.graph.show_indicator(self.item_id)
+
+
+def wrap_legend_items(items, max_width, hspacing, vspacing):
+    def line_width(line):
+        return sum(item.boundingRect().width() for item in line) \
+            + hspacing * (len(line) - 1)
+
+    def create_line(line, yi, fixed_width=None):
+        x = 0
+        for item in line:
+            item.setPos(x, yi * vspacing)
+            paragraph.addToGroup(item)
+            if fixed_width:
+                x += fixed_width
+            else:
+                x += item.boundingRect().width() + hspacing
+
+    max_item = max(item.boundingRect().width() + hspacing for item in items)
+    in_line = int(max_width // max_item)
+    if line_width(items) < max_width:  # single line
+        lines = [items]
+        fixed_width = None
+    elif in_line < 2:
+        lines = [[]]
+        for i, item in enumerate(items):  # just a single column - free wrap
+            lines[-1].append(item)
+            if line_width(lines[-1]) > max_width and len(lines[-1]) > 1:
+                lines.append([lines[-1].pop()])
+        fixed_width = None
+    else:  # arrange into grid
+        lines = [items[i:i + in_line]
+                 for i in range(0, len(items) + in_line - 1, in_line)]
+        fixed_width = max_item
+
+    paragraph = QGraphicsItemGroup()
+    for yi, line in enumerate(lines):
+        create_line(line, yi, fixed_width=fixed_width)
+    return paragraph


Test patch:
diff --git a/Orange/widgets/visualize/tests/test_owmosaic.py b/Orange/widgets/visualize/tests/test_owmosaic.py
--- a/Orange/widgets/visualize/tests/test_owmosaic.py
+++ b/Orange/widgets/visualize/tests/test_owmosaic.py
@@ -98,7 +98,8 @@ def assertCount(cb_color, cb_attr, areas):
         assertCount(1, [0, 1, 1, 1], 0)
 
     @patch('Orange.widgets.visualize.owmosaic.CanvasRectangle')
-    def test_different_number_of_attributes(self, canvas_rectangle):
+    @patch('Orange.widgets.visualize.owmosaic.QGraphicsItemGroup.addToGroup')
+    def test_different_number_of_attributes(self, _, canvas_rectangle):
         domain = Domain([DiscreteVariable(c, values="01") for c in "abcd"])
         data = Table.from_list(
             domain,