Repository: geopandas/geopandas

Issue:
Consistent legend colors over subplots 
I am plotting two maps, each associated with a column (`c1`, `c2`), and with a legend. 
I want a consistent coloring for the two maps, however, as in right map one categorical feature is missing (`feature1`), the colors change too.

Basically I am looking for a way of mapping a color to a specific categorical feature over subplots, both shown on the map as well as in the legend, no matter if the actual feature value is in the selected column or not. 


```
from shapely.geometry import Polygon
from matplotlib import colors   
import matplotlib.pyplot as plt
import geopandas as gpd

# create sample data
p1 = Polygon([(0.5, 0.5), (1, 0.5), (1, 1), (0.5, 1)])
p2 = Polygon([(0, 0), (0.5, 0), (0.5, 0.5), (0, 0.5)])
p3 = Polygon([(0, 0.5), (0.5, 0.5), (0.5, 1), (0,1)])
gdf = gpd.GeoDataFrame({"c1" : ["feature1","feature2","feature3"],
                        "c2" : ["feature2","feature3","feature3"]},
                       geometry=gpd.GeoSeries((p1,p2,p3)))

# colormap
hmap = colors.ListedColormap(['lightgrey', 'red', 'lightblue'])

# ploting
fig, ax = plt.subplots(ncols=2)
gdf.plot(column="c1", legend=True, categorical=True, cmap=hmap, ax=ax[0])
gdf.plot(column="c2", legend=True, categorical=True, cmap=hmap, ax=ax[1])
```

![test](https://user-images.githubusercontent.com/11331549/50737326-13831580-11c8-11e9-9b9b-fd60210415b8.png)


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=h1) Report
> Merging [#1173](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=desc) into [master](https://codecov.io/gh/geopandas/geopandas/commit/74ff43c38674bf8864c0e44a26ca9edaa20bcdbd?src=pr&el=desc) will **increase** coverage by `0.07%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/geopandas/geopandas/pull/1173/graphs/tree.svg?width=650&token=6TOPMcCBhB&height=150&src=pr)](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1173      +/-   ##
==========================================
+ Coverage   90.19%   90.26%   +0.07%     
==========================================
  Files          21       21              
  Lines        2039     2044       +5     
==========================================
+ Hits         1839     1845       +6     
+ Misses        200      199       -1
```


| [Impacted Files](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [geopandas/plotting.py](https://codecov.io/gh/geopandas/geopandas/pull/1173/diff?src=pr&el=tree#diff-Z2VvcGFuZGFzL3Bsb3R0aW5nLnB5) | `93.84% <100%> (+0.51%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=footer). Last update [74ff43c...9fe7085](https://codecov.io/gh/geopandas/geopandas/pull/1173?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

jorisvandenbossche: Sorry for the slow response on the issues/PRs, but, I was thinking: an alternative solution could also be to actually support categorical column values better? Eg try to fix that we follow the order of the categorical values (and in that way, you can also have categorical values with more categories than are present in your data, for that use case).
martinfleis: @jorisvandenbossche not sure if I understand correctly. You mean changing the default behaviour to return the order of categories as they appear in the df instead of alphabetical sorting? On top of this PR?
jorisvandenbossche: I mean specific handling of columns of categorical dtype. Such columns have a fixed set of categories and order defined, which we currently loose due to how we convert them.
martinfleis: That is true, but even then I feel that having simple way how to pass categories is useful. Sometimes you might want to make categorical plot of numerical data, with specific order or additional categories and forcing users to make categorical column in that case seems to be too much. 

But I agree that we should support categorical dtype for categorical plots, but I would open specific issue about that and make another PR allowing both solutions.
jorisvandenbossche: @martinfleis what happens if there is a category in the data that is not listed in the list passed to `categories` ?

Also, passing `categories` should maybe automatically trigger `categorical=True` ?
martinfleis: > @martinfleis what happens if there is a category in the data that is not listed in the list passed to categories ?

I've added a check for that now raising ValueError.

> Also, passing categories should maybe automatically trigger categorical=True ?

True. Done.

Patch:
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index 9076113b..169fe39d 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -448,6 +448,7 @@ def plot_dataframe(
     markersize=None,
     figsize=None,
     legend_kwds=None,
+    categories=None,
     classification_kwds=None,
     missing_kwds=None,
     aspect="auto",
@@ -514,6 +515,8 @@ def plot_dataframe(
     legend_kwds : dict (default None)
         Keyword arguments to pass to matplotlib.pyplot.legend() or
         matplotlib.pyplot.colorbar().
+    categories : list-like
+        Ordered list-like object of categories to be used for categorical plot.
     classification_kwds : dict (default None)
         Keyword arguments to pass to mapclassify
     missing_kwds : dict (default None)
@@ -625,14 +628,35 @@ def plot_dataframe(
 
     nan_idx = pd.isna(values)
 
+    if categories:
+        categorical = True
+
     # Define `values` as a Series
     if categorical:
         if cmap is None:
             cmap = "tab10"
-        categories = list(set(values[~nan_idx]))
-        categories.sort()
+
+        if categories is None:
+            categories = list(set(values[~nan_idx]))
+            categories.sort()
+        else:
+            if not pd.api.types.is_list_like(categories):
+                raise ValueError(
+                    "Categories must be a list-like object. Objects that are "
+                    "considered list-like are for example Python lists, tuples, sets, "
+                    "NumPy arrays, and Pandas Series."
+                )
+            missing = [x for x in values[~nan_idx] if x not in categories]
+            if missing:
+                raise ValueError(
+                    "Column contains values not listed in categories. "
+                    "Missing categories: {}.".format(missing)
+                )
+
         valuemap = dict((k, v) for (v, k) in enumerate(categories))
         values = np.array([valuemap[k] for k in values[~nan_idx]])
+        vmin = 0 if vmin is None else vmin
+        vmax = max(valuemap.values()) if vmax is None else vmax
 
     if scheme is not None:
         if classification_kwds is None:


Test patch:
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index 2baf53f5..2dec35ed 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -262,6 +262,33 @@ class TestPointPlotting:
         with pytest.raises(TypeError):  # no list allowed for alpha
             ax = self.df2.plot(alpha=[0.7, 0.2])
 
+    def test_categorical_list(self):
+        self.df["cats"] = ["cat1", "cat2"] * 5
+        self.df["nums"] = [1, 2] * 5
+        self.df["singlecat"] = ["cat2"] * 10
+        ax1 = self.df.plot("cats", legend=True)
+        ax2 = self.df.plot("singlecat", categories=["cat1", "cat2"], legend=True)
+        ax3 = self.df.plot("nums", categories=[1, 2], legend=True)
+        point_colors1 = ax1.collections[0].get_facecolors()
+        point_colors2 = ax2.collections[0].get_facecolors()
+        point_colors3 = ax3.collections[0].get_facecolors()
+        np.testing.assert_array_equal(
+            point_colors1[1], point_colors2[0], point_colors3[0]
+        )
+        legend1 = [x.get_markerfacecolor() for x in ax1.get_legend().get_lines()]
+        legend2 = [x.get_markerfacecolor() for x in ax2.get_legend().get_lines()]
+        legend3 = [x.get_markerfacecolor() for x in ax3.get_legend().get_lines()]
+        np.testing.assert_array_equal(legend1, legend2)
+        np.testing.assert_array_equal(legend1, legend3)
+
+        with pytest.raises(ValueError, match="Categories must be a list-like object."):
+            self.df.plot(column="cats", categories="non_list")
+
+        with pytest.raises(
+            ValueError, match="Column contains values not listed in categories."
+        ):
+            self.df.plot(column="cats", categories=["cat1"])
+
     def test_misssing(self):
         self.df.loc[0, "values"] = np.nan
         ax = self.df.plot("values")