diff --git a/doc/_docstrings/objects.Plot.add.ipynb b/doc/_docstrings/objects.Plot.add.ipynb index 365f189143..3ef5928a95 100644 --- a/doc/_docstrings/objects.Plot.add.ipynb +++ b/doc/_docstrings/objects.Plot.add.ipynb @@ -19,7 +19,14 @@ { "cell_type": "raw", "id": "33cd5d3c-d3ad-4e3b-bdac-350f8e104594", - "metadata": {}, + "metadata": { + "editable": true, + "raw_mimetype": "", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "Every layer must be defined with a :class:`Mark`:" ] @@ -175,7 +182,13 @@ "cell_type": "code", "execution_count": null, "id": "45690aaa-1abf-40ae-be3b-1ab648f8be62", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "(\n", @@ -185,11 +198,53 @@ ")" ] }, + { + "cell_type": "raw", + "id": "e62f9e80-bfba-4516-a43a-a265dc35eb79", + "metadata": { + "editable": true, + "raw_mimetype": "", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Providing a `label` will annotate the layer in the plot's legend:" + ] + }, { "cell_type": "code", "execution_count": null, "id": "a403012a-e895-4e5b-b690-dc27efbeccad", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "(\n", + " so.Plot(tips, x=\"size\")\n", + " .add(so.Line(color=\"C1\"), so.Agg(), y=\"total_bill\", label=\"Bill\")\n", + " .add(so.Line(color=\"C2\"), so.Agg(), y=\"tip\", label=\"Tip\")\n", + " .label(y=\"Value\")\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c14526a4-37bb-4f4c-84fa-e5c556eee5c2", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [] } diff --git a/doc/_docstrings/objects.Plot.label.ipynb b/doc/_docstrings/objects.Plot.label.ipynb index 1497c56504..1a9f02f93a 100644 --- a/doc/_docstrings/objects.Plot.label.ipynb +++ b/doc/_docstrings/objects.Plot.label.ipynb @@ -5,6 +5,10 @@ "execution_count": null, "id": "9252d5a5-8af1-4f99-b799-ee044329fb23", "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, "tags": [ "hide" ] @@ -19,7 +23,14 @@ { "cell_type": "raw", "id": "fb32137a-e882-4222-9463-b8cf0ee1c8bd", - "metadata": {}, + "metadata": { + "editable": true, + "raw_mimetype": "", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "Use strings to override default labels:" ] @@ -28,7 +39,13 @@ "cell_type": "code", "execution_count": null, "id": "65b4320e-6fb9-48ed-9132-53b0d21b85e6", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p = (\n", @@ -41,7 +58,14 @@ { "cell_type": "raw", "id": "a39626d2-76f5-40a9-a3fd-6f44dd69bd30", - "metadata": {}, + "metadata": { + "editable": true, + "raw_mimetype": "", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "Pass a function to *modify* the default label:" ] @@ -50,7 +74,13 @@ "cell_type": "code", "execution_count": null, "id": "c3540c54-1c91-4d55-8f58-cd758abbe2fd", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p.label(color=str.capitalize)" @@ -59,7 +89,13 @@ { "cell_type": "markdown", "id": "68f3b321-0755-4ef1-a9e6-bcff61a9178d", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "Use this method to set the title for a single-axes plot:" ] @@ -68,7 +104,13 @@ "cell_type": "code", "execution_count": null, "id": "12d23c6e-781f-4b5c-a6b0-3ea0317ab7fb", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p.label(title=\"Penguin species exhibit distinct bill shapes\")" @@ -77,7 +119,13 @@ { "cell_type": "markdown", "id": "8e0bcb80-0929-4ab9-b5c0-13bb3d8e4484", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "When faceting, the `title` parameter will modify default titles:" ] @@ -86,7 +134,13 @@ "cell_type": "code", "execution_count": null, "id": "da1516b7-b823-41c0-b251-01bdecb6a4e6", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p.facet(\"sex\").label(title=str.upper)" @@ -95,7 +149,13 @@ { "cell_type": "markdown", "id": "bb439eae-6cc3-4a6c-bef2-b4b7746edbd1", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "And the `col`/`row` parameters will add labels to the title for each facet:" ] @@ -104,7 +164,13 @@ "cell_type": "code", "execution_count": null, "id": "e0d49ba9-0507-4358-b477-2e0253f0df8f", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p.facet(\"sex\").label(col=\"Sex:\")" @@ -113,7 +179,13 @@ { "cell_type": "markdown", "id": "99471c06-1b1a-4ef5-844c-5f4aa8f322f5", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "source": [ "If more customization is needed, a format string can work well:" ] @@ -122,7 +194,13 @@ "cell_type": "code", "execution_count": null, "id": "848be3a3-5a2c-4b98-918f-825257be85ae", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [ "p.facet(\"sex\").label(title=\"{} penguins\".format)" @@ -132,7 +210,65 @@ "cell_type": "code", "execution_count": null, "id": "94012def-dd7c-48f4-8830-f77a3bf7299b", - "metadata": {}, + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "p" + ] + }, + { + "cell_type": "raw", + "id": "e9b669e9-fd3d-4292-9c8d-e5fb093932b2", + "metadata": { + "editable": true, + "raw_mimetype": "", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "When adding labels for each layer, the `legend=` parameter sets the title for the legend:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78d22763-3f92-4be1-bc3f-bc24ad39da70", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "(\n", + " so.Plot(penguins, x=\"species\")\n", + " .add(so.Line(color=\"C1\"), so.Agg(), y=\"bill_length_mm\", label=\"length\")\n", + " .add(so.Line(color=\"C2\"), so.Agg(), y=\"bill_depth_mm\", label=\"depth\")\n", + " .label(legend=\"Measurement\")\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c7a7b91-bb5c-4bf5-99f8-719a220e3b36", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, "outputs": [], "source": [] } diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index e8ad2aab07..b8bcc00fce 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -66,6 +66,7 @@ class Layer(TypedDict, total=False): vars: dict[str, VariableSpec] orient: str legend: bool + label: str | None class FacetSpec(TypedDict, total=False): @@ -490,6 +491,7 @@ def add( *transforms: Stat | Move, orient: str | None = None, legend: bool = True, + label: str | None = None, data: DataSource = None, **variables: VariableSpec, ) -> Plot: @@ -516,6 +518,8 @@ def add( orientation will be inferred from characteristics of the data and scales. legend : bool Option to suppress the mark/mappings for this layer from the legend. + label : str + A label to use for the layer in the legend, independent of any mappings. data : DataFrame or dict Data source to override the global source provided in the constructor. variables : data vectors or identifiers @@ -568,6 +572,7 @@ def add( "vars": variables, "source": data, "legend": legend, + "label": label, "orient": {"v": "x", "h": "y"}.get(orient, orient), # type: ignore }) @@ -766,7 +771,12 @@ def limit(self, **limits: tuple[Any, Any]) -> Plot: new._limits.update(limits) return new - def label(self, *, title=None, **variables: str | Callable[[str], str]) -> Plot: + def label( + self, *, + title: str | None = None, + legend: str | None = None, + **variables: str | Callable[[str], str] + ) -> Plot: """ Control the labels and titles for axes, legends, and subplots. @@ -780,8 +790,12 @@ def label(self, *, title=None, **variables: str | Callable[[str], str]) -> Plot: For semantic variables, the value sets the legend title. For faceting variables, `title=` modifies the subplot-specific label, while `col=` and/or `row=` add a label for the faceting variable. + When using a single subplot, `title=` sets its title. + The `legend=` parameter sets the title for the "layer" legend + (i.e., when using `label` in :meth:`Plot.add`). + Examples -------- .. include:: ../docstrings/objects.Plot.label.rst @@ -791,6 +805,8 @@ def label(self, *, title=None, **variables: str | Callable[[str], str]) -> Plot: new = self._clone() if title is not None: new._labels["title"] = title + if legend is not None: + new._labels["legend"] = legend new._labels.update(variables) return new @@ -1478,7 +1494,7 @@ def get_order(var): view["ax"].autoscale_view() if layer["legend"]: - self._update_legend_contents(p, mark, data, scales) + self._update_legend_contents(p, mark, data, scales, layer["label"]) def _unscale_coords( self, subplots: list[dict], df: DataFrame, orient: str, @@ -1654,6 +1670,7 @@ def _update_legend_contents( mark: Mark, data: PlotData, scales: dict[str, Scale], + layer_label: str | None, ) -> None: """Add legend artists / labels for one layer in the plot.""" if data.frame.empty and data.frames: @@ -1664,9 +1681,24 @@ def _update_legend_contents( else: legend_vars = list(data.frame.columns.intersection(list(scales))) + # First handle layer legends, which occupy a single entry in legend_contents. + if layer_label is not None: + legend_title = str(p._labels.get("legend", "")) + layer_key = (legend_title, -1) + artist = mark._legend_artist([], None, {}) + if artist is not None: + for content in self._legend_contents: + if content[0] == layer_key: + content[1].append(artist) + content[2].append(layer_label) + break + else: + self._legend_contents.append((layer_key, [artist], [layer_label])) + + # Then handle the scale legends # First pass: Identify the values that will be shown for each variable schema: list[tuple[ - tuple[str, str | int], list[str], tuple[list, list[str]] + tuple[str, str | int], list[str], tuple[list[Any], list[str]] ]] = [] schema = [] for var in legend_vars: @@ -1702,24 +1734,24 @@ def _make_legend(self, p: Plot) -> None: # Input list has an entry for each distinct variable in each layer # Output dict has an entry for each distinct variable merged_contents: dict[ - tuple[str, str | int], tuple[list[Artist], list[str]], + tuple[str, str | int], tuple[list[tuple[Artist, ...]], list[str]], ] = {} for key, new_artists, labels in self._legend_contents: # Key is (name, id); we need the id to resolve variable uniqueness, # but will need the name in the next step to title the legend - if key in merged_contents: - # Copy so inplace updates don't propagate back to legend_contents - existing_artists = merged_contents[key][0] - for i, artist in enumerate(existing_artists): - # Matplotlib accepts a tuple of artists and will overlay them - if isinstance(artist, tuple): - artist += new_artists[i], - else: - existing_artists[i] = artist, new_artists[i] + if key not in merged_contents: + # Matplotlib accepts a tuple of artists and will overlay them + new_artist_tuples = [tuple([a]) for a in new_artists] + merged_contents[key] = new_artist_tuples, labels else: - merged_contents[key] = new_artists.copy(), labels + existing_artists = merged_contents[key][0] + for i, new_artist in enumerate(new_artists): + existing_artists[i] += tuple([new_artist]) - # TODO explain + # When using pyplot, an "external" legend won't be shown, so this + # keeps it inside the axes (though still attached to the figure) + # This is necessary because matplotlib layout engines currently don't + # support figure legends — ideally this will change. loc = "center right" if self._pyplot else "center left" base_legend = None @@ -1727,7 +1759,7 @@ def _make_legend(self, p: Plot) -> None: legend = mpl.legend.Legend( self._figure, - handles, + handles, # type: ignore # matplotlib/issues/26639 labels, title=name, loc=loc, diff --git a/seaborn/_marks/base.py b/seaborn/_marks/base.py index 0f3b81f32a..ac8fdf4aa5 100644 --- a/seaborn/_marks/base.py +++ b/seaborn/_marks/base.py @@ -224,7 +224,7 @@ def _plot( def _legend_artist( self, variables: list[str], value: Any, scales: dict[str, Scale], - ) -> Artist: + ) -> Artist | None: return None diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index bf69864cfb..f61c0ae0d2 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -2127,6 +2127,30 @@ def test_legend_has_no_offset(self, xy): for text in legend.texts: assert float(text.get_text()) > 1e7 + def test_layer_legend(self, xy): + + p = Plot(**xy).add(MockMark(), label="a").add(MockMark(), label="b").plot() + legend = p._figure.legends[0] + assert legend.texts + for text, expected in zip(legend.texts, "ab"): + assert text.get_text() == expected + + def test_layer_legend_with_scale_legend(self, xy): + + s = pd.Series(["a", "b", "a", "c"], name="s") + p = Plot(**xy, color=s).add(MockMark(), label="x").plot() + + legend = p._figure.legends[0] + texts = [t.get_text() for t in legend.findobj(mpl.text.Text)] + assert "x" in texts + for val in s.unique(): + assert val in texts + + def test_layer_legend_title(self, xy): + + p = Plot(**xy).add(MockMark(), label="x").label(legend="layer").plot() + assert p._figure.legends[0].get_title().get_text() == "layer" + class TestDefaultObject: