prettymaps/prettymaps/draw.py

1023 wiersze
32 KiB
Python

"""
Prettymaps - A minimal Python library to draw pretty maps from OpenStreetMap Data
Copyright (C) 2021 Marcelo Prates
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import re
import os
import json
import yaml
#import IPython
import pathlib
import warnings
import matplotlib
import shapely.ops
import numpy as np
import osmnx as ox
import pandas as pd
import geopandas as gp
import shapely.affinity
from copy import deepcopy
from .fetch import get_gdfs
from dataclasses import dataclass
from matplotlib import pyplot as plt
from matplotlib.colors import hex2color
from matplotlib.patches import Path, PathPatch
from typing import Optional, Union, Tuple, List, Dict, Any, Iterable
from shapely.geometry.base import BaseGeometry
from shapely.geometry import Point, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection, box
#import vsketch
class Subplot:
"""
Class implementing a prettymaps Subplot. Attributes:
- query: prettymaps.plot() query
- kwargs: dictionary of prettymaps.plot() parameters
"""
def __init__(self, query, **kwargs):
self.query = query
self.kwargs = kwargs
@dataclass
class Plot:
"""
Dataclass implementing a prettymaps Plot object. Attributes:
- geodataframes: A dictionary of GeoDataFrames (one for each plot layer)
- fig: A matplotlib figure
- ax: A matplotlib axis object
- background: Background layer (shapely object)
"""
geodataframes: Dict[str, gp.GeoDataFrame]
fig: matplotlib.figure.Figure
ax: matplotlib.axes.Axes
background: BaseGeometry
@dataclass
class Preset:
"""
Dataclass implementing a prettymaps Preset object. Attributes:
- params: dictionary of prettymaps.plot() parameters
"""
params: dict
'''
def _ipython_display_(self):
"""
Implements the _ipython_display_() function for the Preset class.
'params' will be displayed as a Markdown table with annotated hex colors
"""
def light_color(hexstring):
rgb = np.array(hex2color(hexstring))
return rgb.mean() > .5
def annotate_colors(text):
matches = re.findall(
'#(?:\\d|[a-f]|[A-F]){6}|#(?:\\d|[a-f]|[A-F]){4}|#(?:\\d|[a-f]|[A-F]){3}', text)
for match in matches:
text = text.replace(
match,
f'<span style="background-color:{match}; color:{"#000" if light_color(match) else "#fff"}">{match}</span>'
)
return text
params = pd.DataFrame(self.params)
params = params.applymap(lambda x: annotate_colors(
yaml.dump(x, default_flow_style=False).replace('\n', '<br>')))
params.iloc[1:, 2:] = ''
IPython.display.display(IPython.display.Markdown(params.to_markdown()))
'''
def transform_gdfs(
gdfs: Dict[str, gp.GeoDataFrame],
x: float = 0,
y: float = 0,
scale_x: float = 1,
scale_y: float = 1,
rotation: float = 0
) -> Dict[str, gp.GeoDataFrame]:
"""
Apply geometric transformations to dictionary of GeoDataFrames
Args:
gdfs (Dict[str, gp.GeoDataFrame]): Dictionary of GeoDataFrames
x (float, optional): x-axis translation. Defaults to 0.
y (float, optional): y-axis translation. Defaults to 0.
scale_x (float, optional): x-axis scale. Defaults to 1.
scale_y (float, optional): y-axis scale. Defaults to 1.
rotation (float, optional): rotation angle (in radians). Defaults to 0.
Returns:
Dict[str, gp.GeoDataFrame]: dictionary of transformed GeoDataFrames
"""
# Project geometries
gdfs = {
name: ox.project_gdf(gdf) if len(gdf) > 0 else gdf for name, gdf in gdfs.items()
}
# Create geometry collection from gdfs' geometries
collection = GeometryCollection(
[GeometryCollection(list(gdf.geometry)) for gdf in gdfs.values()]
)
# Translation, scale & rotation
collection = shapely.affinity.translate(collection, x, y)
collection = shapely.affinity.scale(
collection, scale_x, scale_y)
collection = shapely.affinity.rotate(collection, rotation)
# Update geometries
for i, layer in enumerate(gdfs):
gdfs[layer].geometry = list(collection.geoms[i].geoms)
# Reproject
if len(gdfs[layer]) > 0:
gdfs[layer] = ox.project_gdf(gdfs[layer], to_crs="EPSG:4326")
return gdfs
def PolygonPatch(
shape: BaseGeometry,
**kwargs
) -> PathPatch:
"""_summary_
Args:
shape (BaseGeometry): Shapely geometry
kwargs: parameters for matplotlib's PathPatch constructor
Returns:
PathPatch: matplotlib PatchPatch created from input shapely geometry
"""
# Init vertices and codes lists
vertices, codes = [], []
for poly in shape.geoms if isinstance(shape, Iterable) else [shape]:
# Get polygon's exterior and interiors
exterior = np.array(poly.exterior.xy)
interiors = [np.array(interior.xy) for interior in poly.interiors]
# Append to vertices and codes lists
vertices += [exterior] + interiors
codes += list(
map(
# Ring coding
lambda p: [Path.MOVETO]
+ [Path.LINETO] * (p.shape[1] - 2)
+ [Path.CLOSEPOLY],
[exterior] + interiors,
)
)
# Generate PathPatch
return PathPatch(
Path(np.concatenate(vertices, 1).T, np.concatenate(codes)), **kwargs
)
def plot_gdf(
layer: str,
gdf: gp.GeoDataFrame,
ax: matplotlib.axes.Axes,
mode: str = 'matplotlib',
#vsk: Optional[vsketch.SketchClass] = None,
vsk=None,
palette: Optional[List[str]] = None,
width: Optional[Union[dict, float]] = None,
union: bool = False,
dilate_points: Optional[float] = None,
dilate_lines: Optional[float] = None,
**kwargs,
) -> None:
"""
Plot a layer
Args:
layer (str): layer name
gdf (gp.GeoDataFrame): GeoDataFrame
ax (matplotlib.axes.Axes): matplotlib axis object
mode (str): drawing mode. Options: 'matplotlib', 'vsketch'. Defaults to 'matplotlib'
vsk (Optional[vsketch.SketchClass]): Vsketch object. Mandatory if mode == 'plotter'
palette (Optional[List[str]], optional): Color palette. Defaults to None.
width (Optional[Union[dict, float]], optional): Street widths. Either a dictionary or a float. Defaults to None.
union (bool, optional): Whether to join geometries. Defaults to False.
dilate_points (Optional[float], optional): Amount of dilation to be applied to point (1D) geometries. Defaults to None.
dilate_lines (Optional[float], optional): Amount of dilation to be applied to line (2D) geometries. Defaults to None.
Raises:
Exception: _description_
"""
# Get hatch and hatch_c parameter
hatch_c = kwargs.pop("hatch_c") if "hatch_c" in kwargs else None
# Convert GDF to shapely geometries
geometries = gdf_to_shapely(
layer, gdf, width, point_size=dilate_points, line_width=dilate_lines
)
geometries = geometries.geoms if isinstance(
geometries, Iterable) else [geometries]
# Unite geometries
if union:
geometries = shapely.ops.unary_union(geometries)
if (palette is None) and ("fc" in kwargs) and (type(kwargs["fc"]) != str):
palette = kwargs.pop("fc")
# Plot shapes
for shape in geometries:
if mode == "matplotlib":
if type(shape) in [Polygon, MultiPolygon]:
# Plot main shape (without silhouette)
ax.add_patch(
PolygonPatch(
shape,
lw=0,
ec=hatch_c
if hatch_c
else kwargs["ec"]
if "ec" in kwargs
else None,
fc=kwargs["fc"]
if "fc" in kwargs
else np.random.choice(palette)
if palette
else None,
**{
k: v
for k, v in kwargs.items()
if k not in ["lw", "ec", "fc"]
},
),
)
# Plot just silhouette
ax.add_patch(
PolygonPatch(
shape,
fill=False,
**{
k: v
for k, v in kwargs.items()
if k not in ["hatch", "fill"]
},
)
)
elif type(shape) == LineString:
ax.plot(
*shape.xy,
c=kwargs["ec"] if "ec" in kwargs else None,
**{
k: v
for k, v in kwargs.items()
if k in ["lw", "lt", "dashes", "zorder"]
},
)
elif type(shape) == MultiLineString:
for c in shape.geoms:
ax.plot(
*c.xy,
c=kwargs["ec"] if "ec" in kwargs else None,
**{
k: v
for k, v in kwargs.items()
if k in ["lw", "lt", "dashes", "zorder"]
},
)
elif mode == "plotter":
if ("draw" not in kwargs) or kwargs["draw"]:
# Set stroke
if "stroke" in kwargs:
vsk.stroke(kwargs["stroke"])
else:
vsk.stroke(1)
# Set pen width
if "penWidth" in kwargs:
vsk.penWidth(kwargs["penWidth"])
else:
vsk.penWidth(0.3)
if "fill" in kwargs:
vsk.fill(kwargs["fill"])
else:
vsk.noFill()
vsk.geometry(shape)
else:
raise Exception(f"Unknown mode {mode}")
##########
def plot_legends(gdf, ax):
for _, row in gdf.iterrows():
name = row.name
x, y = np.concatenate(row.geometry.centroid.xy)
ax.text(x, y, name)
##########
def graph_to_shapely(
gdf: gp.GeoDataFrame,
width: float = 1.
) -> BaseGeometry:
"""
Given a GeoDataFrame containing a graph (street newtork),
convert them to shapely geometries by applying dilation given by 'width'
Args:
gdf (gp.GeoDataFrame): input GeoDataFrame containing graph (street network) geometries
width (float, optional): Line geometries will be dilated by this amount. Defaults to 1..
Returns:
BaseGeometry: Shapely
"""
def highway_to_width(highway):
if (type(highway) == str) and (highway in width):
return width[highway]
elif isinstance(highway, Iterable):
for h in highway:
if h in width:
return width[h]
return np.nan
else:
return np.nan
# Annotate GeoDataFrame with the width for each highway type
gdf["width"] = gdf.highway.map(
highway_to_width) if type(width) == dict else width
# Remove rows with inexistent width
gdf.drop(gdf[gdf.width.isna()].index, inplace=True)
with warnings.catch_warnings():
# Supress shapely.errors.ShapelyDeprecationWarning
warnings.simplefilter(
"ignore", shapely.errors.ShapelyDeprecationWarning)
if not all(gdf.width.isna()):
# Dilate geometries based on their width
gdf.geometry.update(
gdf.apply(lambda row: row.geometry.buffer(row.width), axis=1)
)
return shapely.ops.unary_union(gdf.geometry)
def geometries_to_shapely(
gdf: gp.GeoDataFrame,
point_size: Optional[float] = None,
line_width: Optional[float] = None
) -> GeometryCollection:
"""
Convert geometries in GeoDataFrame to shapely format
Args:
gdf (gp.GeoDataFrame): Input GeoDataFrame
point_size (Optional[float], optional): Point geometries (1D) will be dilated by this amount. Defaults to None.
line_width (Optional[float], optional): Line geometries (2D) will be dilated by this amount. Defaults to None.
Returns:
GeometryCollection: Shapely geometries computed from GeoDataFrame geometries
"""
geoms = gdf.geometry.tolist()
collections = [x for x in geoms if type(x) == GeometryCollection]
points = [x for x in geoms if type(x) == Point] + [
y for x in collections for y in x.geoms if type(y) == Point
]
lines = [x for x in geoms if type(x) in [LineString, MultiLineString]] + [
y
for x in collections
for y in x.geoms
if type(y) in [LineString, MultiLineString]
]
polys = [x for x in geoms if type(x) in [Polygon, MultiPolygon]] + [
y for x in collections for y in x.geoms if type(y) in [Polygon, MultiPolygon]
]
# Convert points into circles with radius "point_size"
if point_size:
points = [x.buffer(point_size)
for x in points] if point_size > 0 else []
if line_width:
lines = [x.buffer(line_width) for x in lines] if line_width > 0 else []
return GeometryCollection(list(points) + list(lines) + list(polys))
def gdf_to_shapely(
layer: str,
gdf: gp.GeoDataFrame,
width: Optional[Union[dict, float]] = None,
point_size: Optional[float] = None,
line_width: Optional[float] = None,
**kwargs
) -> GeometryCollection:
"""
Convert a dict of GeoDataFrames to a dict of shapely geometries
Args:
layer (str): Layer name
gdf (gp.GeoDataFrame): Input GeoDataFrame
width (Optional[Union[dict, float]], optional): Street network width. Can be either a dictionary or a float. Defaults to None.
point_size (Optional[float], optional): Point geometries (1D) will be dilated by this amount. Defaults to None.
line_width (Optional[float], optional): Line geometries (2D) will be dilated by this amount. Defaults to None.
Returns:
GeometryCollection: Output GeoDataFrame
"""
# Project gdf
try:
gdf = ox.project_gdf(gdf)
except:
pass
if layer in ["streets", "railway", "waterway"]:
geometries = graph_to_shapely(gdf, width)
else:
geometries = geometries_to_shapely(
gdf, point_size=point_size, line_width=line_width
)
return geometries
def override_args(
layers: dict,
circle: Optional[bool],
dilate: Optional[Union[float, bool]]
) -> dict:
"""
Override arguments in layers' kwargs
Args:
layers (dict): prettymaps.plot() Layers parameters dict
circle (Optional[bool]): prettymaps.plot() 'Circle' parameter
dilate (Optional[Union[float, bool]]): prettymaps.plot() 'dilate' parameter
Returns:
dict: output dict
"""
override_args = ["circle", "dilate"]
for layer in layers:
for arg in override_args:
if arg not in layers[layer]:
layers[layer][arg] = locals()[arg]
return layers
def override_params(
default_dict: dict,
new_dict: dict
) -> dict:
"""
Override parameters in 'default_dict' with additional parameters from 'new_dict'
Args:
default_dict (dict): Default dict to be overriden with 'new_dict' parameters
new_dict (dict): New dict to override 'default_dict' parameters
Returns:
dict: default_dict overriden with new_dict parameters
"""
final_dict = deepcopy(default_dict)
for key in new_dict.keys():
if type(new_dict[key]) == dict:
if key in final_dict:
final_dict[key] = override_params(
final_dict[key], new_dict[key])
else:
final_dict[key] = new_dict[key]
else:
final_dict[key] = new_dict[key]
return final_dict
def create_background(
gdfs: Dict[str, gp.GeoDataFrame],
style: Dict[str, dict]
) -> Tuple[BaseGeometry, float, float, float, float, float, float]:
"""
Create a background layer given a collection of GeoDataFrames
Args:
gdfs (Dict[str, gp.GeoDataFrame]): Dictionary of GeoDataFrames
style (Dict[str, dict]): Dictionary of matplotlib style parameters
Returns:
Tuple[BaseGeometry, float, float, float, float, float, float]: background geometry, bounds, width and height
"""
# Create background
background_pad = 1.1
if "background" in style and "pad" in style["background"]:
background_pad = style["background"].pop("pad")
background = shapely.affinity.scale(
box(*
shapely.ops.unary_union(ox.project_gdf(gdfs["perimeter"]).geometry).bounds),
background_pad,
background_pad,
)
if "background" in style and "dilate" in style["background"]:
background = background.buffer(style['background'].pop("dilate"))
# Get bounds
xmin, ymin, xmax, ymax = background.bounds
dx, dy = xmax - xmin, ymax - ymin
return background, xmin, ymin, xmax, ymax, dx, dy
def draw_text(
params: Dict[str, dict],
background: BaseGeometry
) -> None:
"""
Draw text with content and matplotlib style parameters specified by 'params' dictionary.
params['text'] should contain the message to be drawn
Args:
params (Dict[str, dict]): matplotlib style parameters for drawing text. params['text'] should contain the message to be drawn.
background (BaseGeometry): Background layer
"""
# Override default osm_credit dict with provided parameters
params = override_params(
dict(
text="\n".join([
"data © OpenStreetMap contributors",
"github.com/marceloprates/prettymaps"
]),
x=0, y=1,
horizontalalignment='left',
verticalalignment='top',
bbox=dict(boxstyle='square', fc='#fff', ec='#000'),
fontfamily='Ubuntu Mono'
),
params
)
x, y, text = [params.pop(k) for k in ['x', 'y', 'text']]
# Get background bounds
xmin, ymin, xmax, ymax = background.bounds
x = np.interp([x], [0, 1], [xmin, xmax])[0]
y = np.interp([y], [0, 1], [ymin, ymax])[0]
plt.text(
x, y, text,
**params
)
def presets_directory():
return os.path.join(pathlib.Path(__file__).resolve().parent, 'presets')
def create_preset(
name: str,
layers: Optional[Dict[str, dict]] = None,
style: Optional[Dict[str, dict]] = None,
circle: Optional[bool] = None,
radius: Optional[Union[float, bool]] = None,
dilate: Optional[Union[float, bool]] = None,
) -> None:
"""
Create a preset file and save it on the presets folder (prettymaps/presets/) under name 'name.json'
Args:
name (str): Preset name
layers (Dict[str, dict], optional): prettymaps.plot() 'layers' parameter dict. Defaults to None.
style (Dict[str, dict], optional): prettymaps.plot() 'style' parameter dict. Defaults to None.
circle (Optional[bool], optional): prettymaps.plot() 'circle' parameter. Defaults to None.
radius (Optional[Union[float, bool]], optional): prettymaps.plot() 'radius' parameter. Defaults to None.
dilate (Optional[Union[float, bool]], optional): prettymaps.plot() 'dilate' parameter. Defaults to None.
"""
# if not os.path.isdir('presets'):
# os.makedirs('presets')
path = os.path.join(presets_directory(), f"{name}.json")
with open(path, "w") as f:
json.dump(
{
"layers": layers,
"style": style,
"circle": circle,
"radius": radius,
"dilate": dilate,
},
f,
ensure_ascii=False,
)
def read_preset(name: str) -> Dict[str, dict]:
"""
Read a preset from the presets folder (prettymaps/presets/)
Args:
name (str): Preset name
Returns:
(Dict[str,dict]): parameters dictionary
"""
path = os.path.join(presets_directory(), f"{name}.json")
with open(path, "r") as f:
# Load params from JSON file
params = json.load(f)
return params
def delete_preset(name: str) -> None:
"""
Delete a preset from the presets folder (prettymaps/presets/)
Args:
name (str): Preset name
"""
path = os.path.join(presets_directory(), f"{name}.json")
if os.path.exists(path):
os.remove(path)
def override_preset(
name: str,
layers: Dict[str, dict] = {},
style: Dict[str, dict] = {},
circle: Optional[float] = None,
radius: Optional[Union[float, bool]] = None,
dilate: Optional[Union[float, bool]] = None
) -> Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]:
"""
Read the preset file given by 'name' and override it with additional parameters
Args:
name (str): _description_
layers (Dict[str, dict], optional): _description_. Defaults to {}.
style (Dict[str, dict], optional): _description_. Defaults to {}.
circle (Union[float, None], optional): _description_. Defaults to None.
radius (Union[float, None], optional): _description_. Defaults to None.
dilate (Union[float, None], optional): _description_. Defaults to None.
Returns:
Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]: Preset parameters overriden by additional provided parameters
"""
params = read_preset(name)
# Override preset with kwargs
if "layers" in params:
layers = override_params(params["layers"], layers)
if "style" in params:
style = override_params(params["style"], style)
if circle is None and "circle" in params:
circle = params["circle"]
if radius is None and "radius" in params:
radius = params["radius"]
if dilate is None and "dilate" in params:
dilate = params["dilate"]
# Delete layers marked as 'False' in the parameter dict
for layer in [key for key in layers.keys() if layers[key] == False]:
del layers[layer]
# Return overriden presets
return layers, style, circle, radius, dilate
def manage_presets(
load_preset: Optional[str],
save_preset: bool,
update_preset: Optional[str],
layers: Dict[str, dict],
style: Dict[str, dict],
circle: Optional[bool],
radius: Optional[Union[float, bool]],
dilate: Optional[Union[float, bool]]
) -> Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]:
"""_summary_
Args:
load_preset (Optional[str]): Load preset named 'load_preset', if provided
save_preset (Optional[str]): Save preset to file named 'save_preset', if provided
update_preset (Optional[str]): Load, update and save preset named 'update_preset', if provided
layers (Dict[str, dict]): prettymaps.plot() 'layers' parameter dict
style (Dict[str, dict]): prettymaps.plot() 'style' parameter dict
circle (Optional[bool]): prettymaps.plot() 'circle' parameter
radius (Optional[Union[float, bool]]): prettymaps.plot() 'radius' parameter
dilate (Optional[Union[float, bool]]): prettymaps.plot() 'dilate' parameter
Returns:
Tuple[dict, dict, Optional[float], Optional[Union[float, bool]], Optional[Union[float, bool]]]: Updated layers, style, circle, radius, dilate parameters
"""
# Update preset mode: load a preset, update it with additional parameters and update the JSON file
if update_preset:
load_preset = save_preset = True
# Load preset (if provided)
if load_preset:
layers, style, circle, radius, dilate = override_preset(
load_preset,
layers, style, circle, radius, dilate
)
# Save parameters as preset
if save_preset:
create_preset(
save_preset,
layers=layers,
style=style,
circle=circle,
radius=radius,
dilate=dilate,
)
return layers, style, circle, radius, dilate
def presets():
presets = [file.split('.')[0] for file in os.listdir(
presets_directory()) if file.endswith('.json')]
presets = sorted(presets)
presets = pd.DataFrame({
'preset': presets,
'params': list(map(read_preset, presets))
})
#print('Available presets:')
# for i, preset in enumerate(presets):
# print(f'{i+1}. {preset}')
return pd.DataFrame(presets)
def preset(name):
with open(os.path.join(presets_directory(), f"{name}.json"), "r") as f:
# Load params from JSON file
params = json.load(f)
return Preset(params)
# Plot
def plot(
# Your query. Example:
# - "Porto Alegre"
# - (-30.0324999, -51.2303767) (lat/long coordinates)
# - You can also provide a custom GeoDataFrame boundary as input
query: Union[str, Tuple[float, float], gp.GeoDataFrame],
# Which OpenStreetMap layers to plot
# Example: {'building': {'tags': {'building': True}}, 'streets': {'width': 2}}
# Run prettymaps.presets() for more examples
layers={},
# Matplotlib params for drawing each layer
style={},
# Whether to load params from preset
preset='default',
# Whether to save preset
save_preset=None,
# Whether to load and update preset with additional parameters
update_preset=None,
# Custom postprocessing function on layers
postprocessing=None,
# Circular boundary? Default: square
circle=None,
# Radius for circular or square boundary
radius=None,
# Dilate boundary by this much
dilate=None,
# Whether to save result
save_as=None,
# Figure parameters
fig=None,
ax=None,
title=None,
figsize=(12, 12),
constrained_layout=True,
# Credit message parameters
credit={},
# Mode ('matplotlib' or 'plotter')
mode='matplotlib',
# Multiplot mode
multiplot=False,
# Whether to display matplotlib
show=True,
# Transform (translation, scale, rotation) parameters
x=0,
y=0,
scale_x=1,
scale_y=1,
rotation=0,
):
"""
Draw a map from OpenStreetMap data.
Parameters
----------
query : string
The address to geocode and use as the central point around which to get the geometries
backup : dict
(Optional) feed the output from a previous 'plot()' run to save time
postprocessing: function
(Optional) Apply a postprocessing step to the 'layers' dict
radius
(Optional) If not None, draw the map centered around the address with this radius (in meters)
layers: dict
Specify the name of each layer and the OpenStreetMap tags to fetch
style: dict
Drawing params for each layer (matplotlib params such as 'fc', 'ec', 'fill', etc.)
osm_credit: dict
OSM Caption parameters
figsize: Tuple
(Optional) Width and Height (in inches) for the Matplotlib figure. Defaults to (10, 10)
ax: axes
Matplotlib axes
title: String
(Optional) Title for the Matplotlib figure
vsketch: Vsketch
(Optional) Vsketch object for pen plotting
x: float
(Optional) Horizontal displacement
y: float
(Optional) Vertical displacement
scale_x: float
(Optional) Horizontal scale factor
scale_y: float
(Optional) Vertical scale factor
rotation: float
(Optional) Rotation in angles (0-360)
Returns
-------
layers: dict
Dictionary of layers (each layer is a Shapely MultiPolygon)
Notes
-----
"""
# 1. Manage presets
layers, style, circle, radius, dilate = manage_presets(
preset, save_preset, update_preset,
layers, style, circle, radius, dilate
)
# 2. Init matplotlib figure
if (mode == "matplotlib") and (ax is None):
fig = plt.figure(figsize=figsize)
ax = plt.subplot(111, aspect='equal')
# 3. Override arguments in layers' kwargs dict
layers = override_args(layers, circle, dilate)
# 4. Fetch geodataframes
gdfs = get_gdfs(query, layers, radius, dilate, -rotation)
# 5. Apply transformations to GeoDataFrames (translation, scale, rotation)
gdfs = transform_gdfs(gdfs, x, y, scale_x, scale_y, rotation)
# 6. Apply a postprocessing function to the GeoDataFrames, if provided
if postprocessing:
gdfs = postprocessing(gdfs)
# 7. Create background GeoDataFrame and get (x,y) bounds
background, xmin, ymin, xmax, ymax, dx, dy = create_background(gdfs, style)
# 8. Draw layers
if mode == "plotter":
# 8.1. Draw layers in plotter (vsketch) mode
'''
class Sketch(vsketch.SketchClass):
def draw(self, vsk: vsketch.Vsketch):
vsk.size("a4", landscape=True)
for layer in gdfs:
if layer in layers:
plot_gdf(
layer,
gdfs[layer],
ax,
width=layers[layer]["width"]
if "width" in layers[layer]
else None,
mode=mode,
vsk=vsk,
**(style[layer] if layer in style else {}),
)
if save_as:
vsk.save(save_as)
def finalize(self, vsk: vsketch.Vsketch):
vsk.vpype("linemerge linesimplify reloop linesort")
sketch = Sketch()
sketch.display()
'''
elif mode == "matplotlib":
# 8.2. Draw layers in matplotlib mode
for layer in gdfs:
if layer in layers:
plot_gdf(
layer,
gdfs[layer],
ax,
width=layers[layer]["width"] if "width" in layers[layer] else None,
**(style[layer] if layer in style else {}),
)
else:
raise Exception(f'Unknown mode {mode}')
# 9. Draw background
if "background" in style:
zorder = style["background"].pop(
"zorder") if "zorder" in style["background"] else -1
ax.add_patch(PolygonPatch(
background, **{k:v for k,v in style["background"].items() if k != 'dilate'}, zorder=zorder))
# 10. Draw credit message
if (mode == "matplotlib") and (credit != False) and (not multiplot):
draw_text(credit, background)
# 11. Ajust figure and create PIL Image
if mode == "matplotlib":
# Adjust axis
ax.axis("off")
ax.axis("equal")
ax.autoscale()
# Adjust padding
plt.subplots_adjust(
left=0, bottom=0, right=1, top=1, wspace=0, hspace=0
)
# Save result
if save_as:
plt.savefig(save_as)
if not show:
plt.close()
return Plot(gdfs, fig, ax, background)
def multiplot(*subplots, figsize=None, credit={}, **kwargs):
fig = plt.figure(figsize=figsize)
ax = plt.subplot(111, aspect='equal')
mode = "plotter" if "plotter" in kwargs and kwargs["plotter"] else "matplotlib"
subplots_results = [
plot(
subplot.query,
ax=ax,
multiplot=True,
**override_params(subplot.kwargs, {k: v for k, v in kwargs.items() if k != 'load_preset' or 'load_preset' not in subplot.kwargs})
)
for subplot in subplots
]
if mode == "matplotlib":
ax.axis("off")
ax.axis("equal")
ax.autoscale()
plt.subplots_adjust(
left=0, bottom=0, right=1, top=1, wspace=0, hspace=0
)
if 'show' in kwargs and not kwargs['show']:
plt.close()
if credit != False:
backgrounds = [result.background for result in subplots_results]
global_background = box(
*shapely.ops.unary_union(backgrounds).bounds)
draw_text(credit, global_background)