Source code for plotnine.geoms.geom_map

from __future__ import annotations

import typing

import numpy as np
import pandas as pd

from ..doctools import document
from ..exceptions import PlotnineError
from ..utils import SIZE_FACTOR, to_rgba
from .geom import geom
from .geom_point import geom_point
from .geom_polygon import geom_polygon

if typing.TYPE_CHECKING:
    from typing import Any

    import numpy.typing as npt
    from shapely.geometry.polygon import LinearRing, Polygon

    from plotnine.iapi import panel_view
    from plotnine.typing import (
        Aes,
        Axes,
        Coord,
        DataLike,
        DrawingArea,
        Layer,
        PathPatch,
    )


[docs]@document class geom_map(geom): """ Draw map feature The map feature are drawn without any special projections. {usage} Parameters ---------- {common_parameters} Notes ----- This geom is best suited for plotting a shapefile read into geopandas dataframe. The dataframe should have a ``geometry`` column. """ DEFAULT_AES = { "alpha": 1, "color": "#111111", "fill": "#333333", "linetype": "solid", "shape": "o", "size": 0.5, "stroke": 0.5, } DEFAULT_PARAMS = { "stat": "identity", "position": "identity", "na_rm": False, } REQUIRED_AES = {"geometry"} def __init__( self, mapping: Aes | None = None, data: DataLike | None = None, **kwargs: Any, ): geom.__init__(self, mapping, data, **kwargs) # Almost all geodataframes loaded from shapefiles # have a geometry column. if "geometry" not in self.mapping: self.mapping["geometry"] = "geometry" def setup_data(self, data: pd.DataFrame) -> pd.DataFrame: if not len(data): return data # Remove any NULL geometries, and remember # All the non-Null shapes in a shapefile are required to be # of the same shape type. bool_idx = np.array([g is not None for g in data["geometry"]]) if not np.all(bool_idx): data = data.loc[bool_idx] # Add polygon limits. Scale training uses them try: bounds = data["geometry"].bounds except AttributeError: # The geometry is not a GeoSeries # Bounds calculation is extracted from # geopandas.base.GeoPandasBase.bounds bounds = pd.DataFrame( np.array([x.bounds for x in data["geometry"]]), columns=["xmin", "ymin", "xmax", "ymax"], index=data.index, ) else: bounds.rename( columns={ "minx": "xmin", "maxx": "xmax", "miny": "ymin", "maxy": "ymax", }, inplace=True, ) data = pd.concat([data, bounds], axis=1) return data def draw_panel( self, data: pd.DataFrame, panel_params: panel_view, coord: Coord, ax: Axes, **params: Any, ): if not len(data): return data.loc[data["color"].isna(), "color"] = "none" data.loc[data["fill"].isna(), "fill"] = "none" data["fill"] = to_rgba(data["fill"], data["alpha"]) geom_type = data.geometry.iloc[0].geom_type if geom_type in ("Polygon", "MultiPolygon"): from matplotlib.collections import PatchCollection data["size"] *= SIZE_FACTOR patches = [PolygonPatch(g) for g in data["geometry"]] coll = PatchCollection( patches, edgecolor=data["color"], facecolor=data["fill"], linestyle=data["linetype"], linewidth=data["size"], zorder=params["zorder"], rasterized=params["raster"], ) ax.add_collection(coll) elif geom_type == "Point": # Extract point coordinates from shapely geom # and plot with geom_point arr = np.array([list(g.coords)[0] for g in data["geometry"]]) data["x"] = arr[:, 0] data["y"] = arr[:, 1] for _, gdata in data.groupby("group"): gdata.reset_index(inplace=True, drop=True) gdata.is_copy = None geom_point.draw_group(gdata, panel_params, coord, ax, **params) elif geom_type == "MultiPoint": # Where n is the length of the dataframe (no. of multipoints), # m is the number of all points in all multipoints # # - MultiPoint -> List of Points (tuples) (n -> m) # - Explode the list, to create a dataframe were each point # is associated with the right aesthetics (n -> m) # - Create x & y columns from the points (m -> m) data["points"] = [ [p.coords[0] for p in mp.geoms] for mp in data["geometry"] ] data = data.explode("points", ignore_index=True) data["x"] = [p[0] for p in data["points"]] data["y"] = [p[1] for p in data["points"]] geom_point.draw_group(data, panel_params, coord, ax, **params) elif geom_type in ("LineString", "MultiLineString"): from matplotlib.collections import LineCollection data["size"] *= SIZE_FACTOR data["color"] = to_rgba(data["color"], data["alpha"]) segments = [] for g in data["geometry"]: if g.geom_type == "LineString": segments.append(g.coords) else: segments.extend(_g.coords for _g in g.geoms) coll = LineCollection( segments, edgecolor=data["color"], linewidth=data["size"], linestyle=data["linetype"], zorder=params["zorder"], rasterized=params["raster"], ) ax.add_collection(coll) else: raise TypeError(f"Could not plot geometry of type '{geom_type}'") @staticmethod def draw_legend( data: pd.Series[Any], da: DrawingArea, lyr: Layer ) -> DrawingArea: """ Draw a rectangle in the box Parameters ---------- data : Series Data Row da : DrawingArea Canvas lyr : layer Layer Returns ------- out : DrawingArea """ data["size"] = data["stroke"] del data["stroke"] return geom_polygon.draw_legend(data, da, lyr)
def PolygonPatch( obj: Polygon, ) -> PathPatch: """ Return a Matplotlib patch from a Polygon/MultiPolygon Geometry Parameters ---------- obj : shapley.geometry.Polygon | shapley.geometry.MultiPolygon A Polygon or MultiPolygon to create a patch for description Returns ------- result : matplotlib.patches.PathPatch A patch representing the shapely geometry Notes ----- This functionality was originally provided by the descartes package by Sean Gillies (BSD license, https://pypi.org/project/descartes) which is nolonger being maintained. """ from matplotlib.patches import PathPatch from matplotlib.path import Path def cw_coords(ring: LinearRing) -> npt.NDArray[Any]: """ Return Clockwise array coordinates Parameters ---------- ring: shapely.geometry.polygon.LinearRing LinearRing Returns ------- out: ndarray (n x 2) array of coordinate points. """ if ring.is_ccw: return np.asarray(ring.coords)[:, :2][::-1] return np.asarray(ring.coords)[:, :2] def ccw_coords(ring: LinearRing) -> npt.NDArray[Any]: """ Return Counter Clockwise array coordinates Parameters ---------- ring: shapely.geometry.polygon.LinearRing LinearRing Returns ------- out: ndarray (n x 2) array of coordinate points. """ if ring.is_ccw: return np.asarray(ring.coords)[:, :2] return np.asarray(ring.coords)[:, :2][::-1] # The interiors are holes in the Polygon # MPL draws a hole if the vertex points are specified # in an opposite direction. So we use Clockwise for # the exterior/shell and Counter-Clockwise for any # interiors/holes if obj.geom_type == "Polygon": _exterior = [Path(cw_coords(obj.exterior))] _interior = [Path(ccw_coords(ring)) for ring in obj.interiors] else: # A MultiPolygon has one or more Polygon geoms. # Concatenate the exterior of all the Polygons # and the interiors _exterior = [] _interior = [] for p in obj.geoms: _exterior.append(Path(cw_coords(p.exterior))) _interior.extend([Path(ccw_coords(ring)) for ring in p.interiors]) path = Path.make_compound_path(*_exterior, *_interior) return PathPatch(path) def check_geopandas(): try: import geopandas # noqa: F401 except ImportError: raise PlotnineError( "geom_map requires geopandas. Please install geopandas." )