diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 29a78c2..f359cc9 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -3,55 +3,206 @@ from __future__ import print_function import warnings import numpy as np +import pandas as pd from six import next from six.moves import xrange -from shapely.geometry import Polygon -def plot_polygon(ax, poly, facecolor='red', edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): - """ Plot a single Polygon geometry """ +def _flatten_multi_geoms(geoms, colors): + """ + Returns Series like geoms and colors, except that any Multi geometries + are split into their components and colors are repeated for all component + in the same Multi geometry. Maintains 1:1 matching of geometry to color. + + "Colors" are treated opaquely and so can actually contain any values. + + Returns + ------- + + components : list of geometry + + component_colors : list of whatever type `colors` contains + """ + components, component_colors = [], [] + + # precondition, so zip can't short-circuit + assert len(geoms) == len(colors) + for geom, color in zip(geoms, colors): + if geom.type.startswith('Multi'): + for poly in geom: + components.append(poly) + # repeat same color for all components + component_colors.append(color) + else: + components.append(geom) + component_colors.append(color) + return components, component_colors + + +def plot_polygon_collection(ax, geoms, colors_or_values, plot_values, + vmin=None, vmax=None, cmap=None, + edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): + """ + Plots a collection of Polygon and MultiPolygon geometries to `ax` + + Parameters + ---------- + + ax : matplotlib.axes.Axes + where shapes will be plotted + + geoms : a sequence of `N` Polygons and/or MultiPolygons (can be mixed) + + colors_or_values : a sequence of `N` values or RGBA tuples + It should have 1:1 correspondence with the geometries (not their components). + + plot_values : bool + If True, `colors_or_values` is interpreted as a list of values, and will + be mapped to colors using vmin/vmax/cmap (which become required). + Otherwise `colors_or_values` is interpreted as a list of colors. + + Returns + ------- + + collection : matplotlib.collections.Collection that was plotted + """ + from descartes.patch import PolygonPatch - a = np.asarray(poly.exterior) - if poly.has_z: - poly = Polygon(zip(*poly.exterior.xy)) + from matplotlib.collections import PatchCollection - # without Descartes, we could make a Patch of exterior - ax.add_patch(PolygonPatch(poly, facecolor=facecolor, linewidth=0, alpha=alpha)) # linewidth=0 because boundaries are drawn separately - ax.plot(a[:, 0], a[:, 1], color=edgecolor, linewidth=linewidth, **kwargs) - for p in poly.interiors: - x, y = zip(*p.coords) - ax.plot(x, y, color=edgecolor, linewidth=linewidth) + components, component_colors_or_values = _flatten_multi_geoms( + geoms, colors_or_values) + + # PatchCollection does not accept some kwargs. + if 'markersize' in kwargs: + del kwargs['markersize'] + collection = PatchCollection([PolygonPatch(poly) for poly in components], + linewidth=linewidth, edgecolor=edgecolor, + alpha=alpha, **kwargs) + + if plot_values: + collection.set_array(np.array(component_colors_or_values)) + collection.set_cmap(cmap) + collection.set_clim(vmin, vmax) + else: + # set_color magically sets the correct combination of facecolor and + # edgecolor, based on collection type. + collection.set_color(component_colors_or_values) + + # If the user set facecolor and/or edgecolor explicitly, the previous + # call to set_color might have overridden it (remember, the 'color' may + # have come from plot_series, not from the user). The user should be + # able to override matplotlib's default behavior, by setting them again + # after set_color. + if 'facecolor' in kwargs: + collection.set_facecolor(kwargs['facecolor']) + if edgecolor: + collection.set_edgecolor(edgecolor) + + ax.add_collection(collection, autolim=True) + ax.autoscale_view() + return collection -def plot_multipolygon(ax, geom, facecolor='red', edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): - """ Can safely call with either Polygon or Multipolygon geometry +def plot_linestring_collection(ax, geoms, colors_or_values, plot_values, + vmin=None, vmax=None, cmap=None, + linewidth=1.0, **kwargs): """ - if geom.type == 'Polygon': - plot_polygon(ax, geom, facecolor=facecolor, edgecolor=edgecolor, alpha=alpha, linewidth=linewidth, **kwargs) - elif geom.type == 'MultiPolygon': - for poly in geom.geoms: - plot_polygon(ax, poly, facecolor=facecolor, edgecolor=edgecolor, alpha=alpha, linewidth=linewidth, **kwargs) + Plots a collection of LineString and MultiLineString geometries to `ax` + Parameters + ---------- -def plot_linestring(ax, geom, color='black', linewidth=1.0, **kwargs): - """ Plot a single LineString geometry """ - a = np.array(geom) - ax.plot(a[:, 0], a[:, 1], color=color, linewidth=linewidth, **kwargs) + ax : matplotlib.axes.Axes + where shapes will be plotted + geoms : a sequence of `N` LineStrings and/or MultiLineStrings (can be mixed) -def plot_multilinestring(ax, geom, color='red', linewidth=1.0, **kwargs): - """ Can safely call with either LineString or MultiLineString geometry + colors_or_values : a sequence of `N` values or RGBA tuples + It should have 1:1 correspondence with the geometries (not their components). + + plot_values : bool + If True, `colors_or_values` is interpreted as a list of values, and will + be mapped to colors using vmin/vmax/cmap (which become required). + Otherwise `colors_or_values` is interpreted as a list of colors. + + Returns + ------- + + collection : matplotlib.collections.Collection that was plotted """ - if geom.type == 'LineString': - plot_linestring(ax, geom, color=color, linewidth=linewidth, **kwargs) - elif geom.type == 'MultiLineString': - for line in geom.geoms: - plot_linestring(ax, line, color=color, linewidth=linewidth, **kwargs) + + from matplotlib.collections import LineCollection + + components, component_colors_or_values = _flatten_multi_geoms( + geoms, colors_or_values) + + # LineCollection does not accept some kwargs. + if 'markersize' in kwargs: + del kwargs['markersize'] + segments = [np.array(linestring)[:, :2] for linestring in components] + collection = LineCollection(segments, + linewidth=linewidth, **kwargs) + + if plot_values: + collection.set_array(np.array(component_colors_or_values)) + collection.set_cmap(cmap) + collection.set_clim(vmin, vmax) + else: + # set_color magically sets the correct combination of facecolor and + # edgecolor, based on collection type. + collection.set_color(component_colors_or_values) + + # If the user set facecolor and/or edgecolor explicitly, the previous + # call to set_color might have overridden it (remember, the 'color' may + # have come from plot_series, not from the user). The user should be + # able to override matplotlib's default behavior, by setting them again + # after set_color. + if 'facecolor' in kwargs: + collection.set_facecolor(kwargs['facecolor']) + + if 'edgecolor' in kwargs: + collection.set_edgecolor(kwargs['edgecolor']) + + ax.add_collection(collection, autolim=True) + ax.autoscale_view() + return collection -def plot_point(ax, pt, marker='o', markersize=2, color='black', **kwargs): - """ Plot a single Point geometry """ - ax.plot(pt.x, pt.y, marker=marker, markersize=markersize, color=color, **kwargs) +def plot_point_collection(ax, geoms, colors_or_values, + vmin=None, vmax=None, cmap=None, + marker='o', markersize=2, **kwargs): + """ + Plots a collection of Point geometries to `ax` + + Parameters + ---------- + + ax : matplotlib.axes.Axes + where shapes will be plotted + + geoms : sequence of `N` Points + + colors_or_values : sequence of color or sequence of numbers + can be a sequence of color specifications of length `N` or a sequence + of `N` numbers to be mapped to colors using vmin, vmax, and cmap. + + Returns + ------- + collection : matplotlib.collections.Collection that was plotted + """ + x = [p.x for p in geoms] + y = [p.y for p in geoms] + + # matplotlib ax.scatter requires RGBA color specifications to be a single 2D + # array, NOT merely a list of 1D arrays. This reshapes that if necessary, + # having no effect on 1D arrays of values. + colors_or_values = np.array([element + for _, element in enumerate(colors_or_values)]) + collection = ax.scatter(x, y, c=colors_or_values, + vmin=vmin, vmax=vmax, cmap=cmap, + marker=marker, s=markersize, **kwargs) + return collection def gencolor(N, colormap='Set1'): @@ -130,21 +281,52 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, if ax is None: fig, ax = plt.subplots(figsize=figsize) ax.set_aspect('equal') - color_generator = gencolor(len(s), colormap=cmap) - for geom in s: - if color is None: - col = next(color_generator) + + num_geoms = len(s.index) + if color: + colors = np.array([color] * num_geoms) + else: + color_generator = gencolor(len(s), colormap=cmap) + colors = np.array([next(color_generator) for _ in xrange(num_geoms)]) + + # plot all Polygons and all MultiPolygon components in the same collection + poly_idx = np.array( + (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) + polys = s.geometry[poly_idx] + if not polys.empty: + # Legacy behavior applies alpha to fill but not to edges. This requires + # plotting them separately (at big performance expense). + if linewidth > 0 and color_kwds.get('alpha', 0.5) < 1.0: + # Plot the fill with default or user-specified alpha, but do not + # draw outlines. + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=linewidth, **edges_kwds) else: - col = color - if geom.type == 'Polygon' or geom.type == 'MultiPolygon': - if 'facecolor' in color_kwds: - plot_multipolygon(ax, geom, linewidth=linewidth, **color_kwds) - else: - plot_multipolygon(ax, geom, facecolor=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'LineString' or geom.type == 'MultiLineString': - plot_multilinestring(ax, geom, color=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'Point': - plot_point(ax, geom, color=col, **color_kwds) + # Optimization: if no alpha on fill, or if no edges, we can plot + # everything in one go. + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=linewidth, **color_kwds) + + # plot all LineStrings and MultiLineString components in same collection + line_idx = np.array( + (s.geometry.type == 'LineString') | + (s.geometry.type == 'MultiLineString')) + lines = s.geometry[line_idx] + if not lines.empty: + plot_linestring_collection(ax, lines, colors[line_idx], False, + linewidth=linewidth, **color_kwds) + + point_idx = np.array(s.geometry.type == 'Point') + points = s.geometry[point_idx] + if not points.empty: + plot_point_collection(ax, points, colors[point_idx], **color_kwds) + plt.draw() return ax @@ -169,7 +351,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, geometries can be plotted. column : str (default None) - The name of the column to be plotted. + The name of the column to be plotted. Ignored if `color` is also set. categorical : bool (default False) If False, cmap will reflect numerical values of the @@ -186,8 +368,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, Line width for geometries. legend : bool (default False) - Plot a legend (Experimental; currently for categorical - plots only) + Plot a legend. Ignored if no `column` is given, or if `color` is given. ax : matplotlib.pyplot.Artist (default None) axes on which to draw the plot @@ -228,6 +409,10 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, warnings.warn("'axes' is deprecated, please use 'ax' instead " "(for consistency with pandas)", FutureWarning) ax = color_kwds.pop('axes') + if column and color: + warnings.warn("Only specify one of 'column' or 'color'. Using 'color'.", + SyntaxWarning) + column = None import matplotlib.pyplot as plt from matplotlib.lines import Line2D @@ -238,52 +423,94 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, return plot_series(s.geometry, cmap=cmap, color=color, ax=ax, linewidth=linewidth, figsize=figsize, **color_kwds) + + if s[column].dtype is np.dtype('O'): + categorical = True + + # Define `values` as a Series + if categorical: + if cmap is None: + cmap = 'Set1' + categories = list(set(s[column].values)) + categories.sort() + valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) + values = np.array([valuemap[k] for k in s[column]]) else: - if s[column].dtype is np.dtype('O'): - categorical = True - if categorical: - if cmap is None: - cmap = 'Set1' - categories = list(set(s[column].values)) - categories.sort() - valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) - values = [valuemap[k] for k in s[column]] + values = s[column] + if scheme is not None: + binning = __pysal_choro(values, scheme, k=k) + values = np.array(binning.yb) + # set categorical to True for creating the legend + categorical = True + binedges = [binning.yb.min()] + binning.bins.tolist() + categories = ['{0:.2f} - {1:.2f}'.format(binedges[i], binedges[i+1]) + for i in range(len(binedges)-1)] + if ax is None: + fig, ax = plt.subplots(figsize=figsize) + ax.set_aspect('equal') + + mn = values.min() if vmin is None else vmin + mx = values.max() if vmax is None else vmax + + # plot all Polygons and all MultiPolygon components in the same collection + poly_idx = np.array( + (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) + polys = s.geometry[poly_idx] + if not polys.empty: + # Legacy behavior applies alpha to fill but not to edges. This requires + # plotting them separately (at big performance expense). + if linewidth > 0 and color_kwds.get('alpha', 0.5) < 1.0: + # Plot the fill with default or user-specified alpha, but do not + # draw outlines. + plot_polygon_collection(ax, polys, values[poly_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + # Setting plot_values=False would cause the array values' colors to + # override edgecolor. By setting color instead, matplotlib will + # respect edgecolor if set. + plot_polygon_collection(ax, polys, ['black'] * len(polys), False, + linewidth=linewidth, **edges_kwds) else: - values = s[column] - if scheme is not None: - binning = __pysal_choro(values, scheme, k=k) - values = binning.yb - # set categorical to True for creating the legend - categorical = True - binedges = [binning.yb.min()] + binning.bins.tolist() - categories = ['{0:.2f} - {1:.2f}'.format(binedges[i], binedges[i+1]) - for i in range(len(binedges)-1)] - cmap = norm_cmap(values, cmap, Normalize, cm, vmin=vmin, vmax=vmax) - if ax is None: - fig, ax = plt.subplots(figsize=figsize) - ax.set_aspect('equal') - for geom, value in zip(s.geometry, values): - if color is None: - col = cmap.to_rgba(value) - else: - col = color - if geom.type == 'Polygon' or geom.type == 'MultiPolygon': - plot_multipolygon(ax, geom, facecolor=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'LineString' or geom.type == 'MultiLineString': - plot_multilinestring(ax, geom, color=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'Point': - plot_point(ax, geom, color=col, **color_kwds) - if legend: - if categorical: - patches = [] - for value, cat in enumerate(categories): - patches.append(Line2D([0], [0], linestyle="none", - marker="o", alpha=color_kwds.get('alpha', 0.5), - markersize=10, markerfacecolor=cmap.to_rgba(value))) - ax.legend(patches, categories, numpoints=1, loc='best') - else: - # TODO: show a colorbar - raise NotImplementedError + # Optimization: if no alpha on fill, or if no edges, we can plot + # everything in one go. + plot_polygon_collection(ax, polys, values[poly_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=linewidth, **color_kwds) + + # plot all LineStrings and MultiLineString components in same collection + line_idx = np.array( + (s.geometry.type == 'LineString') | + (s.geometry.type == 'MultiLineString')) + lines = s.geometry[line_idx] + if not lines.empty: + plot_linestring_collection(ax, lines, values[line_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=linewidth, **color_kwds) + + point_idx = np.array(s.geometry.type == 'Point') + points = s.geometry[point_idx] + if not points.empty: + plot_point_collection(ax, points, values[point_idx], + vmin=mn, vmax=mx, cmap=cmap, **color_kwds) + + if legend and not color: + norm = Normalize(vmin=mn, vmax=mx) + n_cmap = cm.ScalarMappable(norm=norm, cmap=cmap) + if categorical: + patches = [] + for value, cat in enumerate(categories): + patches.append(Line2D([0], [0], linestyle="none", + marker="o", alpha=color_kwds.get('alpha', 0.5), + markersize=10, markerfacecolor=n_cmap.to_rgba(value))) + ax.legend(patches, categories, numpoints=1, loc='best') + else: + n_cmap.set_array([]) + ax.get_figure().colorbar(n_cmap) + plt.draw() return ax @@ -325,42 +552,3 @@ def __pysal_choro(values, scheme, k=5): return binning except ImportError: raise ImportError("PySAL is required to use the 'scheme' keyword") - - -def norm_cmap(values, cmap, normalize, cm, vmin=None, vmax=None): - - """ Normalize and set colormap - - Parameters - ---------- - - values - Series or array to be normalized - - cmap - matplotlib Colormap - - normalize - matplotlib.colors.Normalize - - cm - matplotlib.cm - - vmin - Minimum value of colormap. If None, uses min(values). - - vmax - Maximum value of colormap. If None, uses max(values). - - Returns - ------- - n_cmap - mapping of normalized values to colormap (cmap) - - """ - - mn = min(values) if vmin is None else vmin - mx = max(values) if vmax is None else vmax - norm = normalize(vmin=mn, vmax=mx) - n_cmap = cm.ScalarMappable(norm=norm, cmap=cmap) - return n_cmap diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py index 0c684bf..2bcea3f 100644 --- a/geopandas/tests/test_plotting.py +++ b/geopandas/tests/test_plotting.py @@ -1,119 +1,20 @@ from __future__ import absolute_import, division +import itertools import numpy as np -import os -import shutil -import tempfile -from distutils.version import LooseVersion +import warnings import matplotlib matplotlib.use('Agg', warn=False) -from matplotlib.pyplot import Artist, savefig, clf, cm, get_cmap -from matplotlib.testing.noseclasses import ImageComparisonFailure -from matplotlib.testing.compare import compare_images -from numpy import cos, sin, pi -from shapely.geometry import Polygon, LineString, Point +from matplotlib.pyplot import get_cmap +from shapely.affinity import rotate +from shapely.geometry import MultiPolygon, Polygon, LineString, Point from six.moves import xrange from .util import unittest from geopandas import GeoSeries, GeoDataFrame, read_file -# If set to True, generate images rather than perform tests (all tests will pass!) -GENERATE_BASELINE = False - -BASELINE_DIR = os.path.join(os.path.dirname(__file__), 'baseline_images', 'test_plotting') - -TRAVIS = bool(os.environ.get('TRAVIS', False)) -MPL_DEV = matplotlib.__version__ > LooseVersion('1.5.1') - -class TestImageComparisons(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.tempdir = tempfile.mkdtemp() - return - - @classmethod - def tearDownClass(cls): - shutil.rmtree(cls.tempdir) - return - - def _compare_images(self, ax, filename, tol=10): - """ Helper method to do the comparisons """ - assert isinstance(ax, Artist) - if GENERATE_BASELINE: - savefig(os.path.join(BASELINE_DIR, filename)) - savefig(os.path.join(self.tempdir, filename)) - err = compare_images(os.path.join(BASELINE_DIR, filename), - os.path.join(self.tempdir, filename), - tol, in_decorator=True) - if err: - raise ImageComparisonFailure('images not close: %(actual)s ' - 'vs. %(expected)s ' - '(RMS %(rms).3f)' % err) - - @unittest.skipIf(MPL_DEV, 'Skip for development version of matplotlib') - def test_poly_plot(self): - """ Test plotting a simple series of polygons """ - clf() - filename = 'poly_plot.png' - t1 = Polygon([(0, 0), (1, 0), (1, 1)]) - t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - polys = GeoSeries([t1, t2]) - ax = polys.plot() - self._compare_images(ax=ax, filename=filename) - - @unittest.skipIf(MPL_DEV, 'Skip for development version of matplotlib') - def test_point_plot(self): - """ Test plotting a simple series of points """ - clf() - filename = 'points_plot.png' - N = 10 - points = GeoSeries(Point(i, i) for i in xrange(N)) - ax = points.plot() - self._compare_images(ax=ax, filename=filename) - - @unittest.skipIf(MPL_DEV, 'Skip for development version of matplotlib') - def test_line_plot(self): - """ Test plotting a simple series of lines """ - clf() - filename = 'lines_plot.png' - N = 10 - lines = GeoSeries([LineString([(0, i), (9, i)]) for i in xrange(N)]) - ax = lines.plot() - self._compare_images(ax=ax, filename=filename) - - @unittest.skipIf(TRAVIS, 'Skip on Travis (fails even though it passes locally)') - def test_plot_GeoDataFrame_with_kwargs(self): - """ - Test plotting a simple GeoDataFrame consisting of a series of polygons - with increasing values using various extra kwargs. - """ - clf() - filename = 'poly_plot_with_kwargs.png' - ts = np.linspace(0, 2*pi, 10, endpoint=False) - - # Build GeoDataFrame from a series of triangles wrapping around in a ring - # and a second column containing a list of increasing values. - r1 = 1.0 # radius of inner ring boundary - r2 = 1.5 # radius of outer ring boundary - - def make_triangle(t0, t1): - return Polygon([(r1*cos(t0), r1*sin(t0)), - (r2*cos(t0), r2*sin(t0)), - (r1*cos(t1), r1*sin(t1))]) - - polys = GeoSeries([make_triangle(t0, t1) for t0, t1 in zip(ts, ts[1:])]) - values = np.arange(len(polys)) - df = GeoDataFrame({'geometry': polys, 'values': values}) - - # Plot the GeoDataFrame using various keyword arguments to see if they are honoured - ax = df.plot(column='values', cmap=cm.RdBu, vmin=+2, vmax=None, figsize=(8, 4)) - self._compare_images(ax=ax, filename=filename) - - - class TestPointPlotting(unittest.TestCase): def setUp(self): @@ -123,7 +24,14 @@ class TestPointPlotting(unittest.TestCase): values = np.arange(self.N) self.df = GeoDataFrame({'geometry': self.points, 'values': values}) - @unittest.skipIf(MPL_DEV, 'Skip for development version of matplotlib') + def test_figsize(self): + + ax = self.points.plot(figsize=(1, 1)) + np.testing.assert_array_equal(ax.figure.get_size_inches(), (1, 1)) + + ax = self.df.plot(figsize=(1, 1)) + np.testing.assert_array_equal(ax.figure.get_size_inches(), (1, 1)) + def test_default_colors(self): ## without specifying values -> max 9 different colors @@ -132,21 +40,19 @@ class TestPointPlotting(unittest.TestCase): ax = self.points.plot() cmap = get_cmap('Set1', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) # GeoDataFrame -> uses 'jet' instead of 'Set1' ax = self.df.plot() cmap = get_cmap('jet', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) - - ## with specifying values + _check_colors(self.N, ax.collections[0], expected_colors) + ## with specifying values -> different colors for all 10 values ax = self.df.plot(column='values') cmap = get_cmap('jet') expected_colors = cmap(np.arange(self.N)/(self.N-1)) - - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) def test_colormap(self): @@ -156,44 +62,72 @@ class TestPointPlotting(unittest.TestCase): ax = self.points.plot(cmap='RdYlGn') cmap = get_cmap('RdYlGn', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) # GeoDataFrame -> same as GeoSeries in this case ax = self.df.plot(cmap='RdYlGn') - _check_colors(ax.get_lines(), expected_colors) - - ## with specifying values + _check_colors(self.N, ax.collections[0], expected_colors) + ## with specifying values -> different colors for all 10 values ax = self.df.plot(column='values', cmap='RdYlGn') cmap = get_cmap('RdYlGn') expected_colors = cmap(np.arange(self.N)/(self.N-1)) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) def test_single_color(self): ax = self.points.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) - ax = self.df.plot(column='values', color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'column' + ax = self.df.plot(column='values', color='green') + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): # markersize ax = self.points.plot(markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] ax = self.df.plot(markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] ax = self.df.plot(column='values', markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] + + def test_legend(self): + with warnings.catch_warnings(record=True) as _: # don't print warning + # legend ignored if color is given. + ax = self.df.plot(column='values', color='green', legend=True) + assert len(ax.get_figure().axes) == 1 # no separate legend axis + + # legend ignored if no column is given. + ax = self.df.plot(legend=True) + assert len(ax.get_figure().axes) == 1 # no separate legend axis + + # Continuous legend + ## the colorbar matches the Point colors + ax = self.df.plot(column='values', cmap='RdYlGn', legend=True) + point_colors = ax.collections[0].get_facecolors() + cbar_colors = ax.get_figure().axes[1].collections[0].get_facecolors() + ### first point == bottom of colorbar + np.testing.assert_array_equal(point_colors[0], cbar_colors[0]) + ### last point == top of colorbar + np.testing.assert_array_equal(point_colors[-1], cbar_colors[-1]) + + # Categorical legend + ## the colorbar matches the Point colors + ax = self.df.plot(column='values', categorical=True, legend=True) + point_colors = ax.collections[0].get_facecolors() + cbar_colors = ax.get_legend().axes.collections[0].get_facecolors() + ### first point == bottom of colorbar + np.testing.assert_array_equal(point_colors[0], cbar_colors[0]) + ### last point == top of colorbar + np.testing.assert_array_equal(point_colors[-1], cbar_colors[-1]) class TestLineStringPlotting(unittest.TestCase): @@ -202,34 +136,52 @@ class TestLineStringPlotting(unittest.TestCase): self.N = 10 values = np.arange(self.N) - self.lines = GeoSeries([LineString([(0, i), (9, i)]) for i in xrange(self.N)]) + self.lines = GeoSeries([LineString([(0, i), (4, i+0.5), (9, i)]) + for i in xrange(self.N)], + index=list('ABCDEFGHIJ')) self.df = GeoDataFrame({'geometry': self.lines, 'values': values}) def test_single_color(self): ax = self.lines.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) - ax = self.df.plot(column='values', color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'column' + ax = self.df.plot(column='values', color='green') + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): + def linestyle_tuple_to_string(tup): + """ Converts a linestyle of the form `(offset, onoffseq)`, as + documented in `Collections.set_linestyle`, to a string + representation, namely one of: + { 'dashed', 'dotted', 'dashdot', 'solid' }. + """ + from matplotlib.backend_bases import GraphicsContextBase + reverse_idx = dict((v, k) + for k, v in GraphicsContextBase.dashd.items()) + return reverse_idx[tup] + # linestyle ax = self.lines.plot(linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] ax = self.df.plot(linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] ax = self.df.plot(column='values', linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] class TestPolygonPlotting(unittest.TestCase): @@ -238,36 +190,121 @@ class TestPolygonPlotting(unittest.TestCase): t1 = Polygon([(0, 0), (1, 0), (1, 1)]) t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - self.polys = GeoSeries([t1, t2]) + self.polys = GeoSeries([t1, t2], index=list('AB')) self.df = GeoDataFrame({'geometry': self.polys, 'values': [0, 1]}) + + multipoly1 = MultiPolygon([t1, t2]) + multipoly2 = rotate(multipoly1, 180) + self.df2 = GeoDataFrame({'geometry': [multipoly1, multipoly2], + 'values': [0, 1]}) return def test_single_color(self): ax = self.polys.plot(color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) ax = self.df.plot(color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) - ax = self.df.plot(column='values', color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'values' + ax = self.df.plot(column='values', color='green') + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) + + def test_optimization(self): + + # when linewidth=0 or no alpha, we don't have to plot polys twice + + ax = self.polys.plot(linewidth=0) + assert len(ax.collections) == 1 # only plotted once + + ax = self.polys.plot(alpha=1) + assert len(ax.collections) == 1 # only plotted once + + ax = self.df.plot(linewidth=0) + assert len(ax.collections) == 1 # only plotted once + + ax = self.df.plot(alpha=1) + assert len(ax.collections) == 1 # only plotted once def test_vmin_vmax(self): # when vmin == vmax, all polygons should be the same color + + # non-categorical + ax = self.df.plot(column='values', categorical=False, vmin=0, vmax=0) + actual_colors = ax.collections[0].get_facecolors() + np.testing.assert_array_equal(actual_colors[0], actual_colors[1]) + + # categorical ax = self.df.plot(column='values', categorical=True, vmin=0, vmax=0) - cmap = get_cmap('Set1', 2) - self.assertEqual(ax.patches[0].get_facecolor(), ax.patches[1].get_facecolor()) + actual_colors = ax.collections[0].get_facecolors() + np.testing.assert_array_equal(actual_colors[0], actual_colors[1]) - def test_facecolor(self): - t1 = Polygon([(0, 0), (1, 0), (1, 1)]) - t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - polys = GeoSeries([t1, t2]) - df = GeoDataFrame({'geometry': polys, 'values': [0, 1]}) + def test_style_kwargs(self): - ax = polys.plot(facecolor='k') - _check_colors(ax.patches, ['k']*2, alpha=0.5) + # facecolor overrides default cmap when color is not set + ax = self.polys.plot(facecolor='k') + _check_colors(2, ax.collections[0], ['k']*2, alpha=0.5) + + # facecolor overrides more general-purpose color when both are set + ax = self.polys.plot(color='red', facecolor='k') + _check_colors(2, ax.collections[0], ['k']*2, alpha=0.5) + + # edgecolor + ax = self.polys.plot(edgecolor='red') + np.testing.assert_array_equal([(1, 0, 0, 0.5)], + ax.collections[0].get_edgecolors()) + + ax = self.df.plot('values', edgecolor='red') + np.testing.assert_array_equal([(1, 0, 0, 0.5)], + ax.collections[0].get_edgecolors()) + + def test_multipolygons(self): + + # MultiPolygons + ax = self.df2.plot() + assert len(ax.collections[0].get_paths()) == 4 + cmap = get_cmap('jet', 2) + ## colors are repeated for all components within a MultiPolygon + expected_colors = [cmap(0), cmap(0), cmap(1), cmap(1)] + _check_colors(4, ax.collections[0], expected_colors, alpha=0.5) + + ax = self.df2.plot('values') + ## specifying values -> same as without values in this case. + _check_colors(4, ax.collections[0], expected_colors, alpha=0.5) + + +class TestNonuniformGeometryPlotting(unittest.TestCase): + + def setUp(self): + + poly = Polygon([(1, 0), (2, 0), (2, 1)]) + line = LineString([(0.5, 0.5), (1, 1), (1, 0.5), (1.5, 1)]) + point = Point(0.75, 0.25) + self.series = GeoSeries([poly, line, point]) + self.df = GeoDataFrame({'geometry': self.series, 'values': [1, 2, 3]}) + return + + def test_colormap(self): + + ax = self.series.plot(cmap='RdYlGn') + cmap = get_cmap('RdYlGn', 3) + # polygon gets extra alpha. See #266 + _check_colors(1, ax.collections[0], [cmap(0)], alpha=0.5) + # N.B. ax.collections[1] contains the edges of the polygon + _check_colors(1, ax.collections[2], [cmap(1)], alpha=1) # line + _check_colors(1, ax.collections[3], [cmap(2)], alpha=1) # point + + def test_style_kwargs(self): + + # markersize -> only the Point gets it + ax = self.series.plot(markersize=10) + assert ax.collections[3].get_sizes() == [10] + + ax = self.df.plot(markersize=10) + assert ax.collections[3].get_sizes() == [10] class TestPySALPlotting(unittest.TestCase): @@ -284,7 +321,7 @@ class TestPySALPlotting(unittest.TestCase): def test_legend(self): ax = self.tracts.plot(column='CRIME', scheme='QUANTILES', k=3, - cmap='OrRd', legend=True) + cmap='OrRd', legend=True) labels = [t.get_text() for t in ax.get_legend().get_texts()] expected = [u'0.00 - 26.07', u'26.07 - 41.97', u'41.97 - 68.89'] @@ -297,20 +334,37 @@ class TestPySALPlotting(unittest.TestCase): cmap='OrRd', legend=True) -def _check_colors(collection, expected_colors, alpha=None): +def _check_colors(N, collection, expected_colors, alpha=None): + """ Asserts that the members of `collection` match the `expected_colors` (in order) - from matplotlib.lines import Line2D + Parameters + ---------- + N : int + The number of geometries believed to be in collection. + matplotlib.collection is implemented such that the number of geoms in + `collection` doesn't have to match the number of colors assignments in + the collection: the colors will cycle to meet the needs of the geoms. + `N` helps us resolve this. + collection : matplotlib.collections.Collection + The colors of this collection's patches are read from `collection.get_facecolors()` + expected_colors : sequence of RGBA tuples + alpha : float (optional) + If set, this alpha transparency will be applied to the `expected_colors`. + (Any transparency on the `collecton` is assumed to be set in its own + facecolor RGBA tuples.) + """ import matplotlib.colors as colors conv = colors.colorConverter - for patch, color in zip(collection, expected_colors): - if isinstance(patch, Line2D): - # points/lines - result = patch.get_color() - else: - # polygons - result = patch.get_facecolor() - assert conv.to_rgba(result) == conv.to_rgba(color, alpha=alpha) + # Convert 2D numpy array to a list of RGBA tuples. + actual_colors = list(collection.get_facecolors()) + actual_colors = map(tuple, actual_colors) + all_actual_colors = list(itertools.islice( + itertools.cycle(actual_colors), N)) + + for actual, expected in zip(all_actual_colors, expected_colors): + assert actual == conv.to_rgba(expected, alpha=alpha), \ + '{} != {}'.format(actual, conv.to_rgba(expected, alpha=alpha)) if __name__ == '__main__':