Repository: mwaskom/seaborn

Issue:
Consider having range mark derive min/max from data when not given
Marks that are based on min/max (i.e. Range or Band) assume that `{var}min`/`{var}max` variables are present. (Currently they fail with a pretty unhelpful error if not present, which we should generally fix). If those variables are not present, should these marks represent the entire range of the value variable?

This would be useful in contexts like the planned `Percentiles` stat; we want that to be able to reduce a distribution to an arbitrary number of percentiles (to show with a dash mark or similar), but we also want to be able to show percentile ranges. Clumsy idea like `Percentiles([25, 75], range=True)` are possible, the proposed behavior would simplify that. I'm not sure if there are any potential downsides to it.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/mwaskom/seaborn/pull/3056?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) Report
> Merging [#3056](https://codecov.io/gh/mwaskom/seaborn/pull/3056?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (f4d1254) into [master](https://codecov.io/gh/mwaskom/seaborn/commit/5386adc5a482ef3d0aef958ebf37d39ce0b06b88?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) (5386adc) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://codecov.io/gh/mwaskom/seaborn/pull/3056/graphs/tree.svg?width=650&height=150&src=pr&token=SLuzHnA9Bl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)](https://codecov.io/gh/mwaskom/seaborn/pull/3056?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom)

```diff
@@           Coverage Diff           @@
##           master    #3056   +/-   ##
=======================================
  Coverage   98.37%   98.38%           
=======================================
  Files          72       72           
  Lines       23503    23537   +34     
=======================================
+ Hits        23121    23156   +35     
+ Misses        382      381    -1     
```


| [Impacted Files](https://codecov.io/gh/mwaskom/seaborn/pull/3056?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom) | Coverage Δ | |
|---|---|---|
| [seaborn/\_marks/area.py](https://codecov.io/gh/mwaskom/seaborn/pull/3056/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fbWFya3MvYXJlYS5weQ==) | `96.51% <100.00%> (+0.17%)` | :arrow_up: |
| [seaborn/\_marks/line.py](https://codecov.io/gh/mwaskom/seaborn/pull/3056/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fbWFya3MvbGluZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/\_marks/test\_area.py](https://codecov.io/gh/mwaskom/seaborn/pull/3056/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvX21hcmtzL3Rlc3RfYXJlYS5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/\_marks/test\_line.py](https://codecov.io/gh/mwaskom/seaborn/pull/3056/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-dGVzdHMvX21hcmtzL3Rlc3RfbGluZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [seaborn/\_core/scales.py](https://codecov.io/gh/mwaskom/seaborn/pull/3056/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Michael+Waskom#diff-c2VhYm9ybi9fY29yZS9zY2FsZXMucHk=) | `89.67% <0.00%> (+0.23%)` | :arrow_up: |

</details>

Patch:
diff --git a/doc/_docstrings/objects.Band.ipynb b/doc/_docstrings/objects.Band.ipynb
index 5419a193..bcd19758 100644
--- a/doc/_docstrings/objects.Band.ipynb
+++ b/doc/_docstrings/objects.Band.ipynb
@@ -13,7 +13,7 @@
    "source": [
     "import seaborn.objects as so\n",
     "from seaborn import load_dataset\n",
-    "fmri = load_dataset(\"fmri\")\n",
+    "fmri = load_dataset(\"fmri\").query(\"region == 'parietal'\")\n",
     "seaice = (\n",
     "    load_dataset(\"seaice\")\n",
     "    .assign(\n",
@@ -22,7 +22,7 @@
     "    )\n",
     "    .query(\"Year >= 1980\")\n",
     "    .astype({\"Year\": str})\n",
-    "    .pivot(\"Day\", \"Year\", \"Extent\")\n",
+    "    .pivot(index=\"Day\", columns=\"Year\", values=\"Extent\")\n",
     "    .filter([\"1980\", \"2019\"])\n",
     "    .dropna()\n",
     "    .reset_index()\n",
@@ -90,8 +90,32 @@
   },
   {
    "cell_type": "raw",
-   "id": "4e817cdd-09a3-4cf6-8602-e9665607bfe1",
+   "id": "9f0c82bf-3457-4ac5-ba48-8930bac03d75",
    "metadata": {},
+   "source": [
+    "When min/max values are not explicitly assigned or added in a transform, the band will cover the full extent of the data:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "309f578e-da3d-4dc5-b6ac-a354321334c8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    so.Plot(fmri, x=\"timepoint\", y=\"signal\", color=\"event\")\n",
+    "    .add(so.Line(linewidth=.5), group=\"subject\")\n",
+    "    .add(so.Band())\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4330a3cd-63fe-470a-8e83-09e9606643b5",
+   "metadata": {},
+   "outputs": [],
    "source": []
   }
  ],
diff --git a/doc/_docstrings/objects.Range.ipynb b/doc/_docstrings/objects.Range.ipynb
index f8e03e3c..cccb2296 100644
--- a/doc/_docstrings/objects.Range.ipynb
+++ b/doc/_docstrings/objects.Range.ipynb
@@ -57,7 +57,7 @@
     "    so.Plot(penguins, x=\"sex\", y=\"body_mass_g\", linestyle=\"species\")\n",
     "    .facet(\"species\")\n",
     "    .add(so.Line(marker=\"o\"), so.Agg())\n",
-    "    .add(so.Range(), so.Est(errorbar=\"pi\"))\n",
+    "    .add(so.Range(), so.Est(errorbar=\"sd\"))\n",
     ")"
    ]
   },
@@ -78,21 +78,39 @@
    "source": [
     "(\n",
     "    penguins\n",
-    "    .rename_axis(\"penguin\")\n",
-    "    .pipe(so.Plot, ymin=\"bill_depth_mm\", ymax=\"bill_length_mm\", x=\"penguin\")\n",
-    "    .add(so.Range(), color=\"island\", linewidth=\"body_mass_g\")\n",
-    "    .scale(x=so.Continuous().tick(count=0), linewidth=(.5, 1.5))\n",
-    "    .facet(row=\"species\", col=\"sex\")\n",
-    "    .layout(size=(8, 4))\n",
-    "    .share(x=False)\n",
-    "    .label(x=\"\", y=\"Size (mm)\")\n",
+    "    .rename_axis(index=\"penguin\")\n",
+    "    .pipe(so.Plot, x=\"penguin\", ymin=\"bill_depth_mm\", ymax=\"bill_length_mm\")\n",
+    "    .add(so.Range(), color=\"island\")\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "2191bec6-a02e-48e0-b92c-69c38826049d",
+   "metadata": {},
+   "source": [
+    "When `min`/`max` variables are neither computed as part of a transform or explicitly assigned, the range will cover the full extent of the data at each unique observation on the orient axis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "63c6352e-4ef5-4cff-940e-35fa5804b2c7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    so.Plot(penguins, x=\"sex\", y=\"body_mass_g\")\n",
+    "    .facet(\"species\")\n",
+    "    .add(so.Dots(pointsize=6))\n",
+    "    .add(so.Range(linewidth=2))\n",
     ")"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "08751ee7-d0a0-4e70-92b4-c1b38ea28890",
+   "id": "c215deb1-e510-4631-b999-737f5f41cae2",
    "metadata": {},
    "outputs": [],
    "source": []
diff --git a/doc/whatsnew/v0.12.1.rst b/doc/whatsnew/v0.12.1.rst
index 6fe496f1..a1ea3292 100644
--- a/doc/whatsnew/v0.12.1.rst
+++ b/doc/whatsnew/v0.12.1.rst
@@ -4,6 +4,8 @@ v0.12.1 (Unreleased)
 
 - |Feature| Added the :class:`objects.Text` mark (:pr:`3051`).
 
+- |Feature| The :class:`Band` and :class:`Range` marks will now cover the full extent of the data if `min` / `max` variables are not explicitly assigned or added in a transform (:pr:`3056`).
+
 - |Fix| Make :class:`objects.PolyFit` robust to missing data (:pr:`3010`).
 
 - |Fix| Fixed a bug that caused an exception when more than two layers with the same mappings were added (:pr:`3055`).
diff --git a/pyproject.toml b/pyproject.toml
index a81df0eb..3a1a2074 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ requires-python = ">=3.7"
 dependencies = [
     "numpy>=1.17",
     "pandas>=0.25",
-    "matplotlib>=3.1",
+    "matplotlib>=3.1,!=3.6.1",
     "typing_extensions; python_version < '3.8'",
 ]
 
diff --git a/seaborn/_marks/area.py b/seaborn/_marks/area.py
index 243a1572..b3602ad7 100644
--- a/seaborn/_marks/area.py
+++ b/seaborn/_marks/area.py
@@ -162,4 +162,9 @@ class Band(AreaBase, Mark):
     def _standardize_coordinate_parameters(self, data, orient):
         # dv = {"x": "y", "y": "x"}[orient]
         # TODO assert that all(ymax >= ymin)?
+        # TODO what if only one exist?
+        other = {"x": "y", "y": "x"}[orient]
+        if not set(data.columns) & {f"{other}min", f"{other}max"}:
+            agg = {f"{other}min": (other, "min"), f"{other}max": (other, "max")}
+            data = data.groupby(orient).agg(**agg).reset_index()
         return data
diff --git a/seaborn/_marks/line.py b/seaborn/_marks/line.py
index ba69acfe..aa83fdaa 100644
--- a/seaborn/_marks/line.py
+++ b/seaborn/_marks/line.py
@@ -204,8 +204,9 @@ class Paths(Mark):
             # Handle datalim update manually
             # https://github.com/matplotlib/matplotlib/issues/23129
             ax.add_collection(lines, autolim=False)
-            xy = np.concatenate(ax_data["segments"])
-            ax.update_datalim(xy)
+            if ax_data["segments"]:
+                xy = np.concatenate(ax_data["segments"])
+                ax.update_datalim(xy)
 
     def _legend_artist(self, variables, value, scales):
 
@@ -270,9 +271,16 @@ class Range(Paths):
                     "linestyles": [],
                 }
 
+            # TODO better checks on what variables we have
+
             vals = resolve_properties(self, keys, scales)
             vals["color"] = resolve_color(self, keys, scales=scales)
 
+            # TODO what if only one exist?
+            if not set(data.columns) & {f"{other}min", f"{other}max"}:
+                agg = {f"{other}min": (other, "min"), f"{other}max": (other, "max")}
+                data = data.groupby(orient).agg(**agg).reset_index()
+
             cols = [orient, f"{other}min", f"{other}max"]
             data = data[cols].melt(orient, value_name=other)[["x", "y"]]
             segments = [d.to_numpy() for _, d in data.groupby(orient)]


Test patch:
diff --git a/tests/_marks/test_area.py b/tests/_marks/test_area.py
index d55a6c75..d725e154 100644
--- a/tests/_marks/test_area.py
+++ b/tests/_marks/test_area.py
@@ -8,7 +8,7 @@ from seaborn._core.plot import Plot
 from seaborn._marks.area import Area, Band
 
 
-class TestAreaMarks:
+class TestArea:
 
     def test_single_defaults(self):
 
@@ -97,7 +97,10 @@ class TestAreaMarks:
         poly = ax.patches[0]
         assert poly.get_facecolor() == to_rgba(c, 0)
 
-    def test_band(self):
+
+class TestBand:
+
+    def test_range(self):
 
         x, ymin, ymax = [1, 2, 4], [2, 1, 4], [3, 3, 5]
         p = Plot(x=x, ymin=ymin, ymax=ymax).add(Band()).plot()
@@ -109,3 +112,17 @@ class TestAreaMarks:
 
         expected_y = [2, 1, 4, 5, 3, 3, 2]
         assert_array_equal(verts[1], expected_y)
+
+    def test_auto_range(self):
+
+        x = [1, 1, 2, 2, 2]
+        y = [1, 2, 3, 4, 5]
+        p = Plot(x=x, y=y).add(Band()).plot()
+        ax = p._figure.axes[0]
+        verts = ax.patches[0].get_path().vertices.T
+
+        expected_x = [1, 2, 2, 1, 1]
+        assert_array_equal(verts[0], expected_x)
+
+        expected_y = [1, 3, 5, 2, 1]
+        assert_array_equal(verts[1], expected_y)
diff --git a/tests/_marks/test_line.py b/tests/_marks/test_line.py
index a3ef7bbd..726daea5 100644
--- a/tests/_marks/test_line.py
+++ b/tests/_marks/test_line.py
@@ -246,6 +246,15 @@ class TestLines:
         assert_array_equal(verts[0], [2, 5])
         assert_array_equal(verts[1], [3, 4])
 
+    def test_single_orient_value(self):
+
+        x = [1, 1, 1]
+        y = [1, 2, 3]
+        p = Plot(x, y).add(Lines()).plot()
+        lines, = p._figure.axes[0].collections
+        paths, = lines.get_paths()
+        assert paths.vertices.shape == (0, 2)
+
 
 class TestRange:
 
@@ -263,6 +272,17 @@ class TestRange:
             assert_array_equal(verts[0], [x[i], x[i]])
             assert_array_equal(verts[1], [ymin[i], ymax[i]])
 
+    def test_auto_range(self):
+
+        x = [1, 1, 2, 2, 2]
+        y = [1, 2, 3, 4, 5]
+
+        p = Plot(x=x, y=y).add(Range()).plot()
+        lines, = p._figure.axes[0].collections
+        paths = lines.get_paths()
+        assert_array_equal(paths[0].vertices, [(1, 1), (1, 2)])
+        assert_array_equal(paths[1].vertices, [(2, 3), (2, 5)])
+
     def test_mapped_color(self):
 
         x = [1, 2, 1, 2]