Repository: mwaskom/seaborn

Issue:
Improve multiple semantic legend titles
Currently the legends for lineplot (#1285) and scatterplot (#1436) don't indicate what variable each of the levels represent. I punted on this but it needs a solution. One option is to cram everything into the legend title but that's not ideal. I think doing something with legend entries that have invisible handles and and then bumping the legend text so it looks centered would work:

```python
f, ax = plt.subplots()

h1 = ax.scatter([], [], s=0, label="Title 1")
h2 = ax.scatter([], [], label="level 1")
h3 = ax.scatter([], [], s=0, label="Title 2")
h4 = ax.scatter([], [], label="level 2")

legend = ax.legend()
for t in legend.get_texts()[::2]:
    xfm = transforms.offset_copy(t.get_transform(), ax.figure, x=-10, units="points")
    t.set_transform(xfm)
```

![image](https://user-images.githubusercontent.com/315810/40725495-95896280-63f1-11e8-96d6-3d1077e4a501.png)


But getting this to be really robust is going to be tricky.

Discussion:

Patch:
diff --git a/seaborn/basic.py b/seaborn/basic.py
index c2916077..868e4749 100644
--- a/seaborn/basic.py
+++ b/seaborn/basic.py
@@ -550,8 +550,10 @@ class _BasicPlotter(object):
             err = "`legend` must be 'brief', 'full', or False"
             raise ValueError(err)
 
-        keys = []
         legend_kwargs = {}
+        keys = []
+
+        title_kws = dict(color="w", s=0, linewidth=0, marker="", dashes="")
 
         def update(var_name, val_name, **kws):
 
@@ -560,6 +562,7 @@ class _BasicPlotter(object):
                 legend_kwargs[key].update(**kws)
             else:
                 keys.append(key)
+
                 legend_kwargs[key] = dict(**kws)
 
         # -- Add a legend for hue semantics
@@ -574,6 +577,11 @@ class _BasicPlotter(object):
         else:
             hue_levels = self.hue_levels
 
+        # Add the hue semantic subtitle
+        if self.hue_label is not None:
+            update((self.hue_label, "title"), self.hue_label, **title_kws)
+
+        # Add the hue semantic labels
         for level in hue_levels:
             if level is not None:
                 color = self.color_lookup(level)
@@ -591,6 +599,11 @@ class _BasicPlotter(object):
         else:
             size_levels = self.size_levels
 
+        # Add the size semantic subtitle
+        if self.size_label is not None:
+            update((self.size_label, "title"), self.size_label, **title_kws)
+
+        # Add the size semantic labels
         for level in size_levels:
             if level is not None:
                 size = self.size_lookup(level)
@@ -598,6 +611,11 @@ class _BasicPlotter(object):
 
         # -- Add a legend for style semantics
 
+        # Add the style semantic title
+        if self.style_label is not None:
+            update((self.style_label, "title"), self.style_label, **title_kws)
+
+        # Add the style semantic labels
         for level in self.style_levels:
             if level is not None:
                 update(self.style_label, level,
@@ -610,11 +628,12 @@ class _BasicPlotter(object):
         legend_order = []
 
         for key in keys:
+
             _, label = key
             kws = legend_kwargs[key]
             kws.setdefault("color", ".2")
             use_kws = {}
-            for attr in self._legend_attributes:
+            for attr in self._legend_attributes + ["visible"]:
                 if attr in kws:
                     use_kws[attr] = kws[attr]
             artist = func([], [], label=label, **use_kws)


Test patch:
diff --git a/seaborn/tests/test_basic.py b/seaborn/tests/test_basic.py
index d413dd14..d9b079dc 100644
--- a/seaborn/tests/test_basic.py
+++ b/seaborn/tests/test_basic.py
@@ -916,8 +916,8 @@ class TestLinePlotter(TestBasicPlotter):
         p.add_legend_data(ax)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
-        assert labels == p.hue_levels
-        assert colors == [p.palette[l] for l in labels]
+        assert labels == ["a"] + p.hue_levels
+        assert colors == ["w"] + [p.palette[l] for l in p.hue_levels]
 
         # --
 
@@ -928,9 +928,9 @@ class TestLinePlotter(TestBasicPlotter):
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         markers = [h.get_marker() for h in handles]
-        assert labels == p.hue_levels == p.style_levels
-        assert colors == [p.palette[l] for l in labels]
-        assert markers == [p.markers[l] for l in labels]
+        assert labels == ["a"] + p.hue_levels == ["a"] + p.style_levels
+        assert colors == ["w"] + [p.palette[l] for l in p.hue_levels]
+        assert markers == [""] + [p.markers[l] for l in p.style_levels]
 
         # --
 
@@ -941,11 +941,11 @@ class TestLinePlotter(TestBasicPlotter):
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         markers = [h.get_marker() for h in handles]
-        expected_colors = ([p.palette[l] for l in p.hue_levels]
-                           + [".2" for _ in p.style_levels])
-        expected_markers = (["None" for _ in p.hue_levels]
-                            + [p.markers[l] for l in p.style_levels])
-        assert labels == p.hue_levels + p.style_levels
+        expected_colors = (["w"] + [p.palette[l] for l in p.hue_levels]
+                           + ["w"] + [".2" for _ in p.style_levels])
+        expected_markers = ([""] + ["None" for _ in p.hue_levels]
+                            + [""] + [p.markers[l] for l in p.style_levels])
+        assert labels == ["a"] + p.hue_levels + ["b"] + p.style_levels
         assert colors == expected_colors
         assert markers == expected_markers
 
@@ -958,9 +958,9 @@ class TestLinePlotter(TestBasicPlotter):
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_color() for h in handles]
         widths = [h.get_linewidth() for h in handles]
-        assert labels == p.hue_levels == p.size_levels
-        assert colors == [p.palette[l] for l in labels]
-        assert widths == [p.sizes[l] for l in labels]
+        assert labels == ["a"] + p.hue_levels == ["a"] + p.size_levels
+        assert colors == ["w"] + [p.palette[l] for l in p.hue_levels]
+        assert widths == [0] + [p.sizes[l] for l in p.size_levels]
 
         # --
 
@@ -1250,7 +1250,10 @@ class TestScatterPlotter(TestBasicPlotter):
     def test_legend_data(self, long_df):
 
         m = mpl.markers.MarkerStyle("o")
-        default_marker = m.get_path().transformed(m.get_transform())
+        default_mark = m.get_path().transformed(m.get_transform())
+
+        m = mpl.markers.MarkerStyle("")
+        null_mark = m.get_path().transformed(m.get_transform())
 
         f, ax = plt.subplots()
 
@@ -1267,8 +1270,9 @@ class TestScatterPlotter(TestBasicPlotter):
         p.add_legend_data(ax)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_facecolors()[0] for h in handles]
-        assert labels == p.hue_levels
-        assert self.colors_equal(colors, [p.palette[l] for l in labels])
+        expected_colors = ["w"] + [p.palette[l] for l in p.hue_levels]
+        assert labels == ["a"] + p.hue_levels
+        assert self.colors_equal(colors, expected_colors)
 
         # --
 
@@ -1278,10 +1282,12 @@ class TestScatterPlotter(TestBasicPlotter):
         p.add_legend_data(ax)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_facecolors()[0] for h in handles]
+        expected_colors = ["w"] + [p.palette[l] for l in p.hue_levels]
         paths = [h.get_paths()[0] for h in handles]
-        assert labels == p.hue_levels == p.style_levels
-        assert self.colors_equal(colors, [p.palette[l] for l in labels])
-        assert self.paths_equal(paths, [p.paths[l] for l in labels])
+        expected_paths = [null_mark] + [p.paths[l] for l in p.style_levels]
+        assert labels == ["a"] + p.hue_levels == ["a"] + p.style_levels
+        assert self.colors_equal(colors, expected_colors)
+        assert self.paths_equal(paths, expected_paths)
 
         # --
 
@@ -1292,11 +1298,11 @@ class TestScatterPlotter(TestBasicPlotter):
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_facecolors()[0] for h in handles]
         paths = [h.get_paths()[0] for h in handles]
-        expected_colors = ([p.palette[l] for l in p.hue_levels]
-                           + [".2" for _ in p.style_levels])
-        expected_paths = ([default_marker for _ in p.hue_levels]
-                          + [p.paths[l] for l in p.style_levels])
-        assert labels == p.hue_levels + p.style_levels
+        expected_colors = (["w"] + [p.palette[l] for l in p.hue_levels]
+                           + ["w"] + [".2" for _ in p.style_levels])
+        expected_paths = ([null_mark] + [default_mark for _ in p.hue_levels]
+                          + [null_mark] + [p.paths[l] for l in p.style_levels])
+        assert labels == ["a"] + p.hue_levels + ["b"] + p.style_levels
         assert self.colors_equal(colors, expected_colors)
         assert self.paths_equal(paths, expected_paths)
 
@@ -1308,10 +1314,12 @@ class TestScatterPlotter(TestBasicPlotter):
         p.add_legend_data(ax)
         handles, labels = ax.get_legend_handles_labels()
         colors = [h.get_facecolors()[0] for h in handles]
+        expected_colors = ["w"] + [p.palette[l] for l in p.hue_levels]
         sizes = [h.get_sizes()[0] for h in handles]
-        assert labels == p.hue_levels == p.size_levels
-        assert self.colors_equal(colors, [p.palette[l] for l in labels])
-        assert sizes == [p.sizes[l] for l in labels]
+        expected_sizes = [0] + [p.sizes[l] for l in p.size_levels]
+        assert labels == ["a"] + p.hue_levels == ["a"] + p.size_levels
+        assert self.colors_equal(colors, expected_colors)
+        assert sizes == expected_sizes
 
         # --
 
@@ -1610,11 +1618,12 @@ class TestRelPlotter(TestBasicPlotter):
 
         g = basic.relplot(x="x", y="y", hue="a", data=long_df)
         texts = [t.get_text() for t in g._legend.texts]
-        assert np.array_equal(texts, long_df["a"].unique())
+        expected_texts = np.append(["a"], long_df["a"].unique())
+        assert np.array_equal(texts, expected_texts)
 
         g = basic.relplot(x="x", y="y", hue="s", size="s", data=long_df)
         texts = [t.get_text() for t in g._legend.texts]
-        assert np.array_equal(texts, np.sort(texts))
+        assert np.array_equal(texts[1:], np.sort(texts[1:]))
 
         g = basic.relplot(x="x", y="y", hue="a", legend=False, data=long_df)
         assert g._legend is None