Source code for sgis.maps.thematicmap

"""Make static maps with geopandas and matplotlib."""

import warnings
from typing import Any

import matplotlib
import matplotlib.figure
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from geopandas import GeoDataFrame

from ..geopandas_tools.conversion import to_bbox
from ..helpers import is_property
from .legend import LEGEND_KWARGS
from .legend import ContinousLegend
from .legend import Legend
from .legend import prettify_bins
from .map import Map

# the geopandas._explore raises a deprication warning. Ignoring for now.
warnings.filterwarnings(
    action="ignore", category=matplotlib.MatplotlibDeprecationWarning
)
pd.options.mode.chained_assignment = None

MAP_KWARGS = {
    "bins",
    "title",
    "title_fontsize",
    "size",
    "cmap",
    "cmap_start",
    "cmap_stop",
    "scheme",
    "k",
    "column",
    "title_color",
    "facecolor",
    "labelcolor",
    "nan_color",
    # "alpha",
    "title_kwargs",
    "bg_gdf_color",
    "title_position",
    # "linewidth",
}


[docs] class ThematicMap(Map): """Class for making static maps. Args: *gdfs: One or more GeoDataFrames. column: The name of the column to plot. bounds: Optional bounding box for the map. title: Title of the plot. title_position: Title position. Either "center" (default), "left" or "right". size: Width and height of the plot in inches. Fontsize of title and legend is adjusted accordingly. Defaults to 25. dark: If False (default), the background will be white and the text black. If True, the background will be black and the text white. When True, the default cmap is "viridis", and when False, the default is red to purple (RdPu). cmap: Colormap of the plot. See: https://matplotlib.org/stable/tutorials/colors/colormaps.html scheme: How to devide numeric values into categories. Defaults to "naturalbreaks". k: Number of color groups. bins: For numeric columns. List of numbers that define the maximum value for the color groups. nan_label: Label for missing data. legend_kwargs: dictionary with attributes for the legend. E.g.: title: Legend title. Defaults to the column name. rounding: If positive number, it will round floats to n decimals. If negative, eg. -2, the number 3429 is rounded to 3400. By default, the rounding depends on the column's maximum value and standard deviation. position: The legend's x and y position in the plot. By default, it's decided dynamically by finding the space with most distance to the geometries. To be specified as a tuple of x and y position between 0 and 1. E.g. position=(0.8, 0.2) for a position in the bottom right corner, (0.2, 0.8) for the upper left corner. pretty_labels: Whether to capitalize words in text categories. label_suffix: For numeric columns. The text to put after each number in the legend labels. Defaults to None. label_sep: For numeric columns. Text to put in between the two numbers in each color group in the legend. Defaults to '-'. thousand_sep: For numeric columns. Separator between each thousand for large numbers. Defaults to None, meaning no separator. decimal_mark: For numeric columns. Text to use as decimal point. Defaults to None, meaning '.' (dot) unless 'thousand_sep' is '.'. In this case, ',' (comma) will be used as decimal mark. **kwargs: Additional attributes for the map. E.g.: title_color (str): Color of the title font. title_fontsize (int): Color of the title font. cmap_start (int): Start position for the color palette. cmap_stop (int): End position for the color palette. facecolor (str): Background color. labelcolor (str): Color for the labels. nan_color: Color for missing data. Examples: --------- >>> import sgis as sg >>> points = sg.random_points(100, loc=1000).pipe(sg.buff, np.random.rand(100) * 100) >>> points2 = sg.random_points(100, loc=1000).pipe(sg.buff, np.random.rand(100) * 100) Simple plot with legend and title. >>> m = sg.ThematicMap(points, points2, column="area", title="Area of random circles") >>> m.plot() Plot with custom legend units (label_suffix) and thousand separator. And with rounding set to -2, meaning e.g. 3429 is rounded to 3400. If rounding was set to positive 2, 3429 would be rounded to 3429.00. >>> m = sg.ThematicMap( ... points, ... points2, ... column="area", ... title = "Area of random circles", ... legend_kwargs=dict( ... rounding=-2, ... thousand_sep=" ", ... label_sep="to", ... ), ... ) >>> m.plot() With custom bins for the categories, and other customizations. >>> m = sg.ThematicMap( ... points, ... points2, ... column="area", ... cmap="Greens", ... cmap_start=50, ... cmap_stop=255, ... nan_label="Missing", ... title = "Area of random circles", ... bins = [5000, 10000, 15000, 20000], ... title_kwargs=dict( ... loc="left", ... y=0.93, ... x=0.025, ... ), ... legend_kwargs=dict( ... thousand_sep=" ", ... label_sep="to", ... decimal_mark=".", ... label_suffix="m2", ... ), ... ) >>> m.plot() """ def __init__( self, *gdfs: GeoDataFrame, column: str | None = None, bounds: tuple | None = None, title: str | None = None, title_position: tuple[float, float] | None = None, size: int = 25, dark: bool = False, cmap: str | None = None, scheme: str = "naturalbreaks", k: int = 5, bins: tuple[float] | None = None, nan_label: str = "Missing", legend_kwargs: dict | None = None, title_kwargs: dict | None = None, legend: bool = True, **kwargs, ) -> None: """Initialiser.""" super().__init__( *gdfs, column=column, scheme=scheme, k=k, bins=bins, nan_label=nan_label, ) self.title = title self._size = size self._dark = dark self.title_kwargs = title_kwargs or {} if title_position and "position" in self.title_kwargs: raise TypeError( "Specify either 'title_position' or title_kwargs position, not both." ) if title_position or "position" in self.title_kwargs: position = self.title_kwargs.pop("position", title_position) error_mess = ( "legend_kwargs position should be a two length tuple/list with two numbers between " "0 and 1 (x, y position)" ) if not hasattr(position, "__len__"): raise TypeError(error_mess) if len(position) != 2: raise ValueError(error_mess) x, y = position if "loc" not in self.title_kwargs: if x < 0.4: self.title_kwargs["loc"] = "left" elif x > 0.6: self.title_kwargs["loc"] = "right" else: self.title_kwargs["loc"] = "center" self.title_kwargs["x"], self.title_kwargs["y"] = x, y self.background_gdfs = [] legend_kwargs = legend_kwargs or {} self._title_fontsize = self._size * 1.9 black = kwargs.pop("black", None) self._dark = self._dark or black if not self.cmap and not self._is_categorical: self._choose_cmap() if not legend: self.legend = None else: self._create_legend() self._dark_or_light() if cmap: self._cmap = cmap for key, value in kwargs.items(): if key not in MAP_KWARGS: self.kwargs[key] = value elif is_property(self, key): setattr(self, f"_{key}", value) else: setattr(self, key, value) for key, value in legend_kwargs.items(): if key not in LEGEND_KWARGS: raise TypeError( f"{self.__class__.__name__} legend_kwargs got an unexpected key {key}" ) if self.legend is not None: try: setattr(self.legend, key, value) except Exception: setattr(self.legend, f"_{key}", value) self.bounds = ( to_bbox(bounds) if bounds is not None else to_bbox(self._gdf.total_bounds) ) self.minx, self.miny, self.maxx, self.maxy = self.bounds self.diffx = self.maxx - self.minx self.diffy = self.maxy - self.miny @property def valid_keywords(self) -> set[str]: """List all valid keywords for the class initialiser.""" return MAP_KWARGS
[docs] def change_cmap(self, cmap: str, start: int = 0, stop: int = 256) -> "ThematicMap": """Change the color palette of the plot. Args: cmap: The colormap. https://matplotlib.org/stable/tutorials/colors/colormaps.html start: Start position for the color palette. Defaults to 0. stop: End position for the color palette. Defaults to 256, which is the end of the color range. """ super().change_cmap(cmap, start, stop) return self
[docs] def add_background( self, gdf: GeoDataFrame, color: str | None = None ) -> "ThematicMap": """Add a GeoDataFrame as a background layer. Args: gdf: a GeoDataFrame. color: Single color. Defaults to gray (shade depends on whether the map facecolor is black or white). """ if color: self.bg_gdf_color = color if not hasattr(self, "_background_gdfs"): self._background_gdfs = gdf else: self._background_gdfs = pd.concat( [self._background_gdfs, gdf], ignore_index=True ) if self.bounds is None: self.bounds = to_bbox(self._gdf.total_bounds) return self
[docs] def plot(self, **kwargs) -> None: """Creates the final plot. This method should be run after customising the map, but before saving. """ kwargs = kwargs | self.kwargs __test = kwargs.pop("__test", False) include_legend = bool(kwargs.pop("legend", self.legend)) if "color" in kwargs: kwargs.pop("column", None) self.legend = None include_legend = False elif hasattr(self, "color"): kwargs.pop("column", None) kwargs["color"] = self.color self.legend = None include_legend = False elif self._is_categorical: kwargs = self._prepare_categorical_plot(kwargs) if self.legend: self.legend._prepare_categorical_legend( categories_colors=self._categories_colors_dict, nan_label=self.nan_label, ) else: kwargs = self._prepare_continous_plot(kwargs) if self.legend: if not self.legend.rounding: self.legend._rounding = self.legend._get_rounding( array=self._gdf.loc[~self._nan_idx, self._column] ) self.legend._prepare_continous_legend( bins=self.bins, colors=self._unique_colors, nan_label=self.nan_label, bin_values=self._bins_unique_values, ) if self.legend and not self.legend._position_has_been_set: self.legend._position = self.legend._get_best_legend_position( self._gdf, k=self._k + bool(len(self._nan_idx)) ) self._prepare_plot(**kwargs) if self.legend: self.ax = self.legend._actually_add_legend(ax=self.ax) self.ax = self._gdf.plot(legend=include_legend, ax=self.ax, **kwargs) if __test: return self
[docs] def save(self, path: str) -> None: """Save figure as image file. To be run after the plot method. Args: path: File path. """ try: plt.savefig(path) except FileNotFoundError: from dapla import FileClient fs = FileClient.get_gcs_file_system() with fs.open(path, "wb") as file: plt.savefig(file)
def _prepare_plot(self, **kwargs) -> None: """Add figure and axis, title and background gdf.""" for attr in self.__dict__.keys(): if attr in self.kwargs: self[attr] = self.kwargs.pop(attr) if attr in kwargs: self[attr] = kwargs.pop(attr) self.fig, self.ax = self._get_matplotlib_figure_and_axix( figsize=(self._size, self._size) ) self.fig.patch.set_facecolor(self.facecolor) self.ax.set_axis_off() if hasattr(self, "_background_gdfs"): self._actually_add_background() elif self.bounds is not None: self.ax.set_xlim( [self.minx - self.diffx * 0.03, self.maxx + self.diffx * 0.03] ) self.ax.set_ylim( [self.miny - self.diffy * 0.03, self.maxy + self.diffy * 0.03] ) if self.title: self.ax.set_title( self.title, **( dict(fontsize=self.title_fontsize, color=self.title_color) | self.title_kwargs ), ) def _prepare_continous_plot(self, kwargs: dict) -> dict: """Create bins and colors.""" self._prepare_continous_map() if self.scheme is None: self.legend = None kwargs["column"] = self.column return kwargs elif self.bins is None: kwargs["column"] = self.column return kwargs else: if self.legend and self.legend.rounding and self.legend.rounding < 0: self.bins = prettify_bins(self.bins, self.legend.rounding) self.bins = list({round(bin_, 5) for bin_ in self.bins}) self.bins.sort() # self.legend._rounding_was = self.legend.rounding # self.legend.rounding = None classified = self._classify_from_bins(self._gdf, bins=self.bins) classified_sequential = self._push_classification(classified) n_colors = len(np.unique(classified_sequential)) - any(self._nan_idx) self._unique_colors = self._get_continous_colors(n=n_colors) self._bins_unique_values = self._make_bin_value_dict( self._gdf, classified_sequential ) colorarray = self._unique_colors[classified_sequential] kwargs["color"] = colorarray if ( self.legend and self.legend.rounding ): # not self.legend._rounding_has_been_set: self.bins = self.legend._set_rounding( bins=self.bins, rounding=self.legend._rounding ) if any(self._nan_idx): self.bins = self.bins + [self.nan_label] return kwargs def _prepare_categorical_plot(self, kwargs: dict) -> dict: """Map values to colors.""" self._make_categories_colors_dict() if self._gdf is not None and len(self._gdf): self._fix_nans() if self._gdf is not None: colorarray = self._gdf["color"] kwargs["color"] = colorarray return kwargs def _actually_add_legend(self) -> None: """Add legend to the axis and fill it with colors and labels.""" if not self.legend._position_has_been_set: self.legend._position = self.legend._get_best_legend_position( self._gdf, k=self._k + bool(len(self._nan_idx)) ) if self._is_categorical: self.ax = self.legend._actually_add_categorical_legend( ax=self.ax, categories_colors=self._categories_colors_dict, nan_label=self.nan_label, ) else: self.ax = self.legend._actually_add_continous_legend( ax=self.ax, bins=self.bins, colors=self._unique_colors, nan_label=self.nan_label, bin_values=self._bins_unique_values, ) def _create_legend(self) -> None: """Instantiate the Legend class.""" if self._is_categorical: self.legend = Legend(title=self._column, size=self._size) else: self.legend = ContinousLegend(title=self._column, size=self._size) def _choose_cmap(self) -> None: """Kwargs is to catch start and stop points for the cmap in __init__.""" if self._dark: self._cmap = "viridis" self.cmap_start = 0 self.cmap_stop = 256 else: self._cmap = "RdPu" self.cmap_start = 23 self.cmap_stop = 256 def _make_bin_value_dict(self, gdf: GeoDataFrame, classified: np.ndarray) -> dict: """Dict with unique values of all bins. Used in labels in ContinousLegend.""" bins_unique_values = { i: list(set(gdf.loc[classified == i, self._column])) for i, _ in enumerate(np.unique(classified)) } return bins_unique_values def _actually_add_background(self) -> None: self.ax.set_xlim([self.minx - self.diffx * 0.03, self.maxx + self.diffx * 0.03]) self.ax.set_ylim([self.miny - self.diffy * 0.03, self.maxy + self.diffy * 0.03]) self._background_gdfs.plot(ax=self.ax, color=self.bg_gdf_color) @staticmethod def _get_matplotlib_figure_and_axix( figsize: tuple[int, int] ) -> tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: fig = plt.figure(figsize=figsize) ax = fig.add_subplot(1, 1, 1) return fig, ax def _dark_or_light(self) -> None: if self._dark: self.facecolor, self.title_color, self.bg_gdf_color = ( "#0f0f0f", "#fefefe", "#383836", ) self.nan_color = "#666666" if not self._is_categorical: self.change_cmap("viridis") if self.legend is not None: for key, color in { "facecolor": "#0f0f0f", "labelcolor": "#fefefe", "title_color": "#fefefe", }.items(): setattr(self.legend, key, color) else: self.facecolor, self.title_color, self.bg_gdf_color = ( "#fefefe", "#0f0f0f", "#e8e6e6", ) self.nan_color = "#c2c2c2" if not self._is_categorical: self.change_cmap("RdPu", start=23) if self.legend is not None: for key, color in { "facecolor": "#fefefe", "labelcolor": "#0f0f0f", "title_color": "#0f0f0f", }.items(): setattr(self.legend, key, color) @property def dark(self) -> bool: """Whether to use dark background and light text colors.""" return self._dark @dark.setter def dark(self, new_value: bool): self._dark = new_value self._dark_or_light() @property def title_fontsize(self) -> int: """Title fontsize, not to be confused with legend.title_fontsize.""" return self._title_fontsize @title_fontsize.setter def title_fontsize(self, new_value: int) -> None: self._title_fontsize = new_value self._title_fontsize_has_been_set = True @property def size(self) -> int: """Size of the image.""" return self._size @size.setter def size(self, new_value: bool) -> None: """Adjust font and marker size if not actively set.""" self._size = new_value if not hasattr(self, "_title_fontsize_has_been_set"): self._title_fontsize = self._size * 2 if not hasattr(self, "legend"): return if not hasattr(self.legend, "_title_fontsize_has_been_set"): self.legend._title_fontsize = self._size * 1.2 if not hasattr(self.legend, "_fontsize_has_been_set"): self.legend._fontsize = self._size if not hasattr(self.legend, "_markersize_has_been_set"): self.legend._markersize = self._size def __setattr__(self, __name: str, __value: Any) -> None: """Set an attribute with square brackets.""" if "legend_" in __name: last_part = __name.split("legend_")[-1] raise AttributeError( f"Invalid attribute {__name!r}. Did you mean 'legend.{last_part}'?" ) return super().__setattr__(__name, __value)