weavingspace.tile_map
Classes for tiling maps. Tiling
and
TiledMap
are exposed in the public API and
respectively enable creation of a tiling and plotting of the tiling as a
multivariate map.
1#!/usr/bin/env python 2# coding: utf-8 3 4"""Classes for tiling maps. `weavingspace.tile_map.Tiling` and 5`weavingspace.tile_map.TiledMap` are exposed in the public API and 6respectively enable creation of a tiling and plotting of the tiling as a 7multivariate map. 8""" 9 10from dataclasses import dataclass 11from typing import Union 12import itertools 13import copy 14 15import numpy as np 16import geopandas as gpd 17import pandas as pd 18 19from matplotlib.figure import Figure 20import matplotlib.colors 21import matplotlib.pyplot as pyplot 22 23import shapely.geometry as geom 24import shapely.affinity as affine 25import shapely.ops 26 27from weavingspace.tileable import Tileable 28from weavingspace.tileable import TileShape 29from weavingspace.tile_unit import TileUnit 30 31from weavingspace import tiling_utils 32 33from time import perf_counter 34 35@dataclass 36class _TileGrid(): 37 """A class to represent the translation centres of a tiling. 38 39 We store the grid as a GeoSeries of Point objects to make it 40 simple to plot it in map views if required. 41 42 Implementation relies on transforming the translation vectors into a square 43 space where the tile spacing is unit squares, then transforming this back 44 into the original map space. Some member variables of the class are in 45 the transformed grid generation space, some in the map space. 46 """ 47 tile:TileUnit = None 48 """the base tile in map space.""" 49 to_tile:gpd.GeoSeries = None 50 """geometry of the region to be tiled in map space.""" 51 transform:tuple[float] = None 52 """the forward transformation from map space to tiling grid generation 53 space, stored as a shapely.affinity transform tuple of 6 floats.""" 54 inverse_transform:tuple[float] = None 55 """the inverse transform from tiling grid space to map space.""" 56 centre:tuple[float] = None 57 """centre point of the extent in map sapce.""" 58 points:gpd.GeoSeries = None 59 """geom.Points recording the translation vectors of the tiling in map space. 60 """ 61 _extent:gpd.GeoSeries = None 62 """geometry of the circular extent of the tiling transformed into tiling 63 grid generation space.""" 64 _at_centroids:bool = False 65 """if True the grid will consist of the centroids of the spatial units in the 66 to_tile region, allowing a simple way to use a tile unit as a point symbol.""" 67 68 def __init__(self, tile:TileUnit, to_tile:gpd.GeoSeries, 69 at_centroids:bool = False): 70 self.tile = tile 71 self.to_tile = self._get_area_to_tile(to_tile) 72 self.inverse_transform, self.transform = self._get_transforms() 73 self.extent, self.centre = self._get_extent() 74 self._at_centroids = at_centroids 75 if self._at_centroids: 76 self.points = to_tile.centroid 77 else: 78 self.points = self._get_grid() 79 self.points.crs = self.tile.crs 80 self.points = tiling_utils.gridify(self.points) 81 82 83 def _get_area_to_tile(self, to_tile) -> geom.Polygon: 84 bb = to_tile.total_bounds 85 poly = tiling_utils.gridify( 86 geom.Polygon(((bb[0], bb[1]), (bb[2], bb[1]), 87 (bb[2], bb[3]), (bb[0], bb[3])))) 88 return gpd.GeoSeries([poly]) 89 90 91 def _get_extent(self) -> tuple[gpd.GeoSeries, geom.Point]: 92 """Returns the extent and centre of the grid. 93 94 Extent is in the grid-generation space. 95 96 Returns: 97 tuple[gpd.GeoSeries, geom.Point]: the extent of the grid and its 98 centre. 99 """ 100 101 # TODO: the minimum_rotate_rectangle seems to throw an error? 102 mrr = self.to_tile[0].minimum_rotated_rectangle 103 mrr_centre = geom.Point(mrr.centroid.coords[0]) 104 mrr_corner = geom.Point(mrr.exterior.coords[0]) 105 radius = mrr_centre.distance(mrr_corner) 106 ext = tiling_utils.gridify( 107 affine.affine_transform(mrr_centre.buffer(radius), self.transform)) 108 return gpd.GeoSeries([ext]), (mrr_centre.x, mrr_centre.y) 109 110 111 def _get_transforms(self) -> tuple[float]: 112 """Returns the forward and inverse transforms from map space to 113 grid generation space. 114 115 In grid generation space the translation vectors are (1, 0) and (0, 1) 116 so we can simply form a matrix from two translation vectors and invert 117 it to get the forward transform. The inverse transform is the matrix 118 formed from the vectors. 119 120 Returns: 121 tuple[float]: shapely affine transform tuples (a, b, d, e, dx, dy). 122 See https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations for details. 123 """ 124 v = self.tile.get_vectors() 125 vector_array = np.array([[v[0][0], v[1][0]], 126 [v[0][1], v[1][1]]]) 127 inv_tfm = np.linalg.inv(vector_array) 128 return (self._np_to_shapely_transform(vector_array), 129 self._np_to_shapely_transform(inv_tfm)) 130 131 132 def _get_grid(self) -> gpd.GeoSeries: 133 """Generates the grid transformed into map space 134 135 Obtain dimensions of the transformed region, then set down a uniform 136 grid. 137 138 Returns: 139 gpd.GeoSeries: the grid as a collection of geom.Points. 140 """ 141 _w, _h, _l, _b = tiling_utils.get_width_height_left_bottom(self.extent) 142 w = int(np.ceil(_w)) 143 h = int(np.ceil(_h)) 144 l = _l - (w - _w) / 2 145 b = _b - (h - _h) / 2 146 mesh = np.array(np.meshgrid(np.arange(w) + l, 147 np.arange(h) + b)).reshape((2, w * h)).T 148 pts = [tiling_utils.gridify(geom.Point(p[0], p[1])) for p in mesh] 149 return gpd.GeoSeries([p for p in pts if p.within(self.extent[0])]) \ 150 .affine_transform(self.inverse_transform) 151 152 153 def _np_to_shapely_transform(self, mat:np.ndarray) -> tuple[float]: 154 """Converts a numpy affine transform matrix to shapely format. 155 156 Args: 157 mat (np.ndarray): numpy array to convert. 158 159 Returns: 160 tuple[float]: shapely affine transform tuple 161 """ 162 return (mat[0][0], mat[0][1], mat[1][0], mat[1][1], 0, 0) 163 164 165@dataclass 166class Tiling: 167 """Class that applies a `Tileable` object to a region to be mapped. 168 169 The result of the tiling procedure is stored in the `tiles` variable and 170 covers a region sufficient that the tiling can be rotated to any desired 171 angle. 172 """ 173 tile_unit:Tileable = None 174 """tileable on which the tiling is based.""" 175 tile_shape:TileShape = None 176 """base shape of the tileable.""" 177 region:gpd.GeoDataFrame = None 178 """the region to be tiled.""" 179 region_union: geom.Polygon = None 180 grid:_TileGrid = None 181 """the grid which will be used to apply the tiling.""" 182 tiles:gpd.GeoDataFrame = None 183 """the tiles after tiling has been carried out.""" 184 prototiles:gpd.GeoDataFrame = None 185 """the prototiles after tiling has been carried out.""" 186 rotation:float = 0.0 187 """the cumulative rotation already applied to the tiling.""" 188 189 def __init__(self, unit:Tileable, region:gpd.GeoDataFrame, id_var = None, 190 prototile_margin:float = 0, tiles_sf:float = 1, 191 tiles_margin:float = 0, as_icons:bool = False) -> None: 192 """Class to persist a tiling by filling an area relative to 193 a region sufficient to apply the tiling at any rotation. 194 195 The Tiling constructor allows a number of adjustments to the supplied 196 `weavingspace.tileable.Tileable` object: 197 198 + `prototile_margin` values greater than 0 will introduce spacing of 199 the specified distance between tiles on the boundary of each tile 200 by applying the `TileUnit.inset_prototile()` method. Note that this 201 operation does not make sense for `WeaveUnit` objects, 202 and may not preserve the equality of tile areas. 203 + `tiles_sf` values less than one scale down tiles by applying the 204 `TileUnit.scale_tiles()` method. Does not make sense for `WeaveUnit` 205 objects. 206 + `tiles_margin` values greater than one apply a negative buffer of 207 the specified distance to every tile in the tiling by applying the 208 `Tileable.inset_tiles()` method. This option is applicable to both 209 `WeaveUnit` and `TileUnit` objects. 210 211 Args: 212 unit (Tileable): the tile_unit to use. 213 region (gpd.GeoDataFrame): the region to be tiled. 214 prototile_margin (float, optional): values greater than 0 apply an 215 inset margin to the tile unit. Defaults to 0. 216 tiles_sf (float, optional): scales the tiles. Defaults to 1. 217 tiles_margin (float, optional): applies a negative buffer to 218 the tiles. Defaults to 0. 219 as_icons (bool, optional): if True prototiles will only be placed at 220 the region's zone centroids, one per zone. Defaults to 221 False. 222 """ 223 self.tile_unit = unit 224 self.rotation = self.tile_unit.rotation 225 if tiles_margin > 0: 226 self.tile_unit = self.tile_unit.inset_tiles(tiles_margin) 227 if tiles_sf != 1: 228 if isinstance(self.tile_unit, TileUnit): 229 self.tile_unit = self.tile_unit.scale_tiles(tiles_sf) 230 else: 231 print(f"""Applying scaling to tiles of a WeaveUnit does not make sense. 232 Ignoring tiles_sf setting of {tiles_sf}.""") 233 if prototile_margin > 0: 234 if isinstance(self.tile_unit, TileUnit): 235 self.tile_unit = self.tile_unit.inset_prototile(prototile_margin) 236 else: 237 print(f"""Applying a prototile margin to a WeaveUnit does 238 not make sense. Ignoring prototile_margin setting of 239 {prototile_margin}.""") 240 self.region = region 241 self.region.sindex 242 self.region_union = self.region.geometry.unary_union 243 if id_var != None: 244 print("""id_var is no longer required and will be deprecated soon. 245 A temporary unique index attribute is added and removed when 246 generating the tiled map.""") 247 if as_icons: 248 self.grid = _TileGrid(self.tile_unit, self.region.geometry, True) 249 else: 250 self.grid = _TileGrid(self.tile_unit, self.region.geometry) 251 self.tiles, self.prototiles = self.make_tiling() 252 self.tiles.sindex 253 254 255 def get_tiled_map(self, rotation:float = 0., 256 join_on_prototiles:bool = True, 257 prioritise_tiles:bool = True, 258 ragged_edges:bool = True, 259 use_centroid_lookup_approximation = False, 260 debug = False) -> "TiledMap": 261 """Returns a `TiledMap` filling a region at the requested rotation. 262 263 HERE BE DRAGONS! This function took a lot of trial and error to get 264 right, so modify with CAUTION! 265 266 The `proritise_tiles = True` option means that the tiling will not 267 break up the tiles in `TileUnit`s at the boundaries between areas 268 in the mapped region, but will instead ensure that tiles remain 269 complete, picking up their data from the region zone which they overlap 270 the most. 271 272 The exact order in which operations are performed affects performance. 273 For example, the final clipping to self.region when ragged_edges = 274 False is _much_ slower if it is carried out before the dissolving of 275 tiles into the region zones. So... again... modify CAREFULLY! 276 277 Args: 278 rotation (float, optional): An optional rotation to apply. Defaults 279 to 0. 280 join_on_prototiles (bool, optional): if True data from the region 281 dataset are joined to tiles based on the prototile to which they 282 belong. If False the join is based on the tiles in relation to the 283 region areas. For weave-based tilings False is probably to be 284 preferred. Defaults to True. 285 prioritise_tiles (bool, optional): if True tiles will not be 286 broken at boundaries in the region dataset. Defaults to True. 287 ragged_edges (bool, optional): if True tiles at the edge of the 288 region will not be cut by the region extent - ignored if 289 prioritise_tiles is False when edges will always be clipped to 290 the region extent. Defaults to True. 291 use_centroid_lookup_approximation (bool, optional): if True use 292 tile centroids for lookup of region data - ignored if 293 prioritise_tiles is False when it is irrelevant. Defaults to 294 False. 295 debug (bool, optional): if True prints timing messages. Defaults 296 to False. 297 298 Returns: 299 TiledMap: a TiledMap of the source region. 300 """ 301 if debug: 302 t1 = perf_counter() 303 304 id_var = self._setup_region_DZID() 305 if join_on_prototiles: 306 tiled_map, join_layer = self.rotated(rotation) 307 tiled_map["joinUID"] = self.tiles["prototile_id"] 308 else: 309 tiled_map = self.rotated(rotation)[0] 310 tiled_map["joinUID"] = self.tiles["tile_id"] 311 join_layer = tiled_map 312 join_layer["joinUID"] = list(range(join_layer.shape[0])) 313 314 # compile a list of the variable names we are NOT going to change 315 # i.e. everything except the geometry and the id_var 316 region_vars = list(self.region.columns) 317 region_vars.remove("geometry") 318 region_vars.remove(id_var) 319 320 if debug: 321 t2 = perf_counter() 322 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 323 324 if prioritise_tiles: # maintain tile continuity across zone boundaries 325 # select only tiles inside a spacing buffer of the region 326 # make column with unique ID for every tile in the tiling 327 # the join ID is unique per tile 328 # if join_on_prototiles: 329 # tiled_map["joinUID"] = self.tiles["prototile_id"] 330 # else: 331 # tiled_map["joinUID"] = self.tiles["tile_id"] 332 333 if use_centroid_lookup_approximation: 334 t5 = perf_counter() 335 tile_pts = copy.deepcopy(join_layer) 336 tile_pts.geometry = tile_pts.centroid 337 lookup = tile_pts.sjoin( 338 self.region, how = "inner")[["joinUID", id_var]] 339 else: 340 # determine areas of overlapping tiles and drop the data we join the 341 # data back later, so dropping makes that easier overlaying in region. 342 # overlay(tiles) seems to be faster?? 343 # TODO: also... this part is performance-critical, think about fixes -- 344 # possibly including the above centroid-based approx 345 overlaps = self.region.overlay(join_layer, make_valid = False) 346 # overlaps = self.region.overlay(tiled_map, make_valid = False) 347 if debug: 348 t3 = perf_counter() 349 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 350 overlaps["area"] = overlaps.geometry.area 351 if debug: 352 t4 = perf_counter() 353 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 354 overlaps.drop(columns = region_vars, inplace = True) 355 if debug: 356 t5 = perf_counter() 357 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 358 # make a lookup by largest area tile to region id 359 lookup = overlaps \ 360 .iloc[overlaps.groupby("joinUID")["area"] \ 361 .agg(pd.Series.idxmax)][["joinUID", id_var]] 362 # now join the lookup and from there the region data 363 if debug: 364 t6 = perf_counter() 365 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 366 tiled_map = tiled_map \ 367 .merge(lookup, on = "joinUID") \ 368 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 369 if debug: 370 t7 = perf_counter() 371 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 372 tiled_map.drop(columns = ["joinUID"], inplace = True) 373 374 else: # here we overlay 375 tiled_map = self.region.overlay(tiled_map) 376 t7 = perf_counter() 377 if debug: 378 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 379 380 if join_on_prototiles: 381 tiled_map = tiled_map.loc[ 382 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 383 384 tiled_map.drop(columns = [id_var], inplace = True) 385 self.region.drop(columns = [id_var], inplace = True) 386 387 # if we've retained tiles and want 'clean' edges, then clip 388 # note that this step is slow: geopandas unary_unions the clip layer 389 if prioritise_tiles and not ragged_edges: 390 tiled_map.sindex 391 tiled_map = tiled_map.clip(self.region) 392 if debug: 393 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 394 395 tm = TiledMap() 396 tm.tiling = self 397 tm.map = tiled_map 398 return tm 399 400 401 def _setup_region_DZID(self) -> str: 402 """Creates a new guaranteed-unique attribute in the self.region 403 dataframe, and returns its name. 404 405 Avoids a name clash with any existing attribute in the dataframe. 406 407 Returns: 408 str: name of the added attribute. 409 """ 410 dzid = "DZID" 411 i = 0 412 while dzid in self.region.columns: 413 dzid = "DZID" + str(i) 414 i = i + 1 415 self.region[dzid] = list(range(self.region.shape[0])) 416 return dzid 417 418 419 def _rotate_gdf_to_geoseries( 420 self, gdf:gpd.GeoDataFrame, 421 angle:float, centre:tuple = (0, 0) 422 ) -> tuple[gpd.GeoSeries, tuple[float]]: 423 """Rotates the geometries in a GeoDataFrame as a single collection. 424 425 Rotation is about the supplied centre or about the centroid of the 426 GeoDataFrame (if not). This allows for reversal of a rotation. [Note 427 that this might not be a required precaution!] 428 429 Args: 430 gdf (geopandas.GeoDataFrame): GeoDataFrame to rotate 431 angle (float): angle of rotation (degrees). 432 centre (tuple, optional): desired centre of rotation. Defaults 433 to (0, 0). 434 435 Returns: 436 tuple: a geopandas.GeoSeries and a tuple (point) of the centre of 437 the rotation. 438 """ 439 centre = ( 440 gdf.geometry.unary_union.centroid.coords[0] 441 if centre is None 442 else centre) 443 return gdf.geometry.rotate(angle, origin = centre), centre 444 445 446 def make_tiling(self) -> gpd.GeoDataFrame: 447 """Tiles the region with a tile unit, returning a GeoDataFrame 448 449 Returns: 450 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 451 tile unit. 452 """ 453 # we assume the geometry column is called geometry so make it so... 454 if self.region.geometry.name != "geometry": 455 self.region.rename_geometry("geometry", inplace = True) 456 457 # chain list of lists of GeoSeries geometries to list of geometries 458 tiles = itertools.chain(*[ 459 self.tile_unit.tiles.geometry.translate(p.x, p.y) 460 for p in self.grid.points]) 461 prototiles = itertools.chain(*[ 462 self.tile_unit.prototile.geometry.translate(p.x, p.y) 463 for p in self.grid.points]) 464 # replicate the tile ids 465 prototile_ids = list(range(len(self.grid.points))) 466 tile_ids = list(self.tile_unit.tiles.tile_id) * len(self.grid.points) 467 tile_prototile_ids = sorted(prototile_ids * self.tile_unit.tiles.shape[0]) 468 tiles_gs = gpd.GeoSeries(tiles) 469 prototiles_gs = gpd.GeoSeries(prototiles) 470 # assemble and return as GeoDataFrames 471 tiles_gdf = gpd.GeoDataFrame( 472 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 473 geometry = tiles_gs, crs = self.tile_unit.crs) 474 prototiles_gdf = gpd.GeoDataFrame( 475 data = {"prototile_id": prototile_ids}, 476 geometry = prototiles_gs, crs = self.tile_unit.crs) 477 # unclear if we need the gridify or not... 478 return (tiling_utils.gridify(tiles_gdf), 479 tiling_utils.gridify(prototiles_gdf)) 480 481 482 def rotated(self, rotation:float = None) -> gpd.GeoDataFrame: 483 """Returns the stored tiling rotated. 484 485 Args: 486 rotation (float, optional): Rotation angle in degrees. 487 Defaults to None. 488 489 Returns: 490 gpd.GeoDataFrame: Rotated tiling. 491 """ 492 if self.tiles is None: 493 self.tiles = self.make_tiling() 494 self.rotation = rotation 495 if self.rotation == 0: 496 return self.tiles, self.prototiles 497 tiles = gpd.GeoDataFrame( 498 data = {"tile_id": self.tiles.tile_id, 499 "prototile_id": self.tiles.tile_id}, 500 crs = self.tiles.crs, 501 geometry = tiling_utils.gridify( 502 self.tiles.geometry.rotate(rotation, origin = self.grid.centre))) 503 prototiles = gpd.GeoDataFrame( 504 data = {"prototile_id": self.prototiles.prototile_id}, 505 crs = self.prototiles.crs, 506 geometry = tiling_utils.gridify( 507 self.prototiles.geometry.rotate(rotation, origin = self.grid.centre))) 508 return tiles, prototiles 509 510 511@dataclass 512class TiledMap: 513 """Class representing a tiled map. Should not be accessed directly, but 514 will be created by calling `Tiling.get_tiled_map()`. After creation the 515 variables and colourmaps attributes can be set, and then 516 `TiledMap.render()` called to make a map. Settable attributes are explained 517 in documentation of the `TiledMap.render()` method. 518 519 Examples: 520 Recommended usage is as follows. First, make a `TiledMap` from a `Tiling` object. 521 522 tm = tiling.get_tiled_map(...) 523 524 Some options in the `Tiling` constructor affect the map appearance. See 525 `Tiling` for details. 526 527 Once a `TiledMap` object exists, set options on it, either when calling 528 `TiledMap.render()` or explicitly, i.e. 529 530 tm.render(opt1 = val1, opt2 = val2, ...) 531 532 or 533 534 tm.opt1 = val1 535 tm.opt2 = val2 536 tm.render() 537 538 Option settings are persistent, i.e. unless a new `TiledMap` object is 539 created the option settings have to be explicitly reset to default 540 values on subsequent calls to `TiledMap.render()`. 541 542 The most important options are the `variables` and `colourmaps` 543 settings. 544 545 `variables` is a dictionary mapping `weavingspace.tileable.Tileable` 546 tile_ids (usually "a", "b", etc.) to variable names in the data. For 547 example, 548 549 tm.variables = dict(zip(["a", "b"], ["population", "income"])) 550 551 `colourmaps` is a dictionary mapping dataset variable names to the 552 matplotlib colourmap to be used for each. For example, 553 554 tm.colourmaps = dict(zip(tm.variables.values(), ["Reds", "Blues"])) 555 556 See [this notebook](https://github.com/DOSull/weaving-space/blob/main/weavingspace/example-tiles-cairo.ipynb) 557 for simple usage. 558 TODO: This more complicated example shows how categorical maps can be 559 created. 560 """ 561 # these will be set at instantion by Tiling.get_tiled_map() 562 tiling:Tiling = None 563 """the Tiling with the required tiles""" 564 map:gpd.GeoDataFrame = None 565 """the GeoDataFrame on which this map is based""" 566 variables:dict[str,str] = None 567 """lookup from tile_id to variable names""" 568 colourmaps:dict[str,Union[str,dict]] = None 569 """lookup from variables to matplotlib cmaps""" 570 571 # the below parameters can be set either before calling self.render() 572 # or passed in as parameters to self.render() 573 # these are solely TiledMap.render() options 574 legend:bool = True 575 """whether or not to show a legend""" 576 legend_zoom:float = 1.0 577 """<1 zooms out from legend to show more context""" 578 legend_dx:float = 0. 579 """x shift of legend relative to the map""" 580 legend_dy:float = 0. 581 """y shift of legend relative to the map""" 582 use_ellipse:bool = False 583 """if True clips legend with an ellipse""" 584 ellipse_magnification:float = 1.0 585 """magnification to apply to clip ellipse""" 586 radial_key:bool = False 587 """if True use radial key even for ordinal/ratio data (normally these will be 588 shown by concentric tile geometries)""" 589 draft_mode:bool = False 590 """if True plot the map coloured by tile_id""" 591 592 # the parameters below are geopandas.plot options which we intercept to 593 # ensure they are applied appropriately when we plot a GDF 594 scheme:str = "equalinterval" 595 """geopandas scheme to apply""" 596 k:int = 100 597 """geopandas number of classes to apply""" 598 figsize:tuple[float] = (20, 15) 599 """maptlotlib figsize""" 600 dpi:float = 72 601 """dpi for bitmap formats""" 602 603 def render(self, **kwargs) -> Figure: 604 """Renders the current state to a map. 605 606 Note that TiledMap objects will usually be created by calling 607 `Tiling.get_tiled_map()`. 608 609 Args: 610 variables (dict[str,str]): Mapping from tile_id values to 611 variable names. Defaults to None. 612 colourmaps (dict[str,Union[str,dict]]): Mapping from variable 613 names to colour map, either a colour palette as used by 614 geopandas/matplotlib, a fixed colour, or a dictionary mapping 615 categorical data values to colours. Defaults to None. 616 legend (bool): If True a legend will be drawn. Defaults to True. 617 legend_zoom (float): Zoom factor to apply to the legend. Values <1 618 will show more of the tile context. Defaults to 1.0. 619 legend_dx (float): x shift to apply to the legend position. 620 Defaults to 0.0. 621 legend_dy (float): x and y shift to apply to the legend position. 622 Defaults to 0.0. 623 use_ellipse (bool): If True applies an elliptical clip to the 624 legend. Defaults to False. 625 ellipse_magnification (float): Magnification to apply to ellipse 626 clipped legend. Defaults to 1.0. 627 radial_key (bool): If True legend key for TileUnit maps will be 628 based on radially dissecting the tiles. Defaults to False. 629 draft_mode (bool): If True a map of the tiled map coloured by 630 tile_ids (and with no legend) is returned. Defaults to False. 631 scheme (str): passed to geopandas.plot for numeric data. Defaults to 632 "equalinterval". 633 k (int): passed to geopandas.plot for numeric data. Defaults to 100. 634 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 635 plot. Defaults to (20,15). 636 dpi (float): passed to pyplot.plot. Defaults to 72. 637 **kwargs: other settings to pass to pyplot/geopandas.plot. 638 639 Returns: 640 matplotlib.figure.Figure: figure on which map is plotted. 641 """ 642 pyplot.rcParams['pdf.fonttype'] = 42 643 pyplot.rcParams['pdf.use14corefonts'] = True 644 matplotlib.rcParams['pdf.fonttype'] = 42 645 646 to_remove = set() # keep track of kwargs we use to setup TiledMap 647 for k, v in kwargs.items(): 648 if k in self.__dict__: 649 self.__dict__[k] = v 650 to_remove.add(k) 651 # remove them so we don't pass them on to pyplot and get errors 652 for k in to_remove: 653 del kwargs[k] 654 655 if self.draft_mode: 656 fig = pyplot.figure(figsize = self.figsize) 657 ax = fig.add_subplot(111) 658 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", 659 **kwargs) 660 return fig 661 662 if self.legend: 663 # this sizing stuff is rough and ready for now, possibly forever... 664 reg_w, reg_h, *_ = \ 665 tiling_utils.get_width_height_left_bottom(self.map.geometry) 666 tile_w, tile_h, *_ = \ 667 tiling_utils.get_width_height_left_bottom( 668 self.tiling.tile_unit._get_legend_tiles().rotate( 669 self.tiling.rotation, origin = (0, 0))) 670 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 671 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 672 "width_ratios": [reg_w, sf_w * tile_w]} 673 674 fig, axes = pyplot.subplot_mosaic( 675 [["map", "legend"], ["map", "."]], 676 gridspec_kw = gskw, figsize = self.figsize, 677 layout = "constrained", **kwargs) 678 else: 679 fig, axes = pyplot.subplots( 680 1, 1, figsize = self.figsize, 681 layout = "constrained", **kwargs) 682 683 if self.variables is None: 684 # get any floating point columns available 685 default_columns = \ 686 self.map.select_dtypes( 687 include = ("float64", "int64")).columns 688 self.variables = dict(zip(self.map.tile_id.unique(), 689 list(default_columns))) 690 print(f"""No variables specified, picked the first 691 {len(self.variables)} numeric ones available.""") 692 elif isinstance(self.variables, (list, tuple)): 693 self.variables = dict(zip( 694 self.tiling.tile_unit.tiles.tile_id.unique(), 695 self.variables)) 696 print(f"""Only a list of variables specified, assigning to 697 available tile_ids.""") 698 699 if self.colourmaps is None: 700 self.colourmaps = {} 701 for var in self.variables.values(): 702 if self.map[var].dtype == pd.CategoricalDtype: 703 self.colourmaps[var] = "tab20" 704 print(f"""For categorical data, you should specify colour 705 mapping explicitly.""") 706 else: 707 self.colourmaps[var] = "Reds" 708 709 self._plot_map(axes, **kwargs) 710 return fig 711 712 713 def _plot_map(self, axes:pyplot.Axes, **kwargs) -> None: 714 """Plots map to the supplied axes. 715 716 Args: 717 axes (pyplot.Axes): axes on which maps will be drawn. 718 """ 719 bb = self.map.geometry.total_bounds 720 if self.legend: 721 axes["map"].set_axis_off() 722 axes["map"].set_xlim(bb[0], bb[2]) 723 axes["map"].set_ylim(bb[1], bb[3]) 724 self._plot_subsetted_gdf(axes["map"], self.map, **kwargs) 725 self.plot_legend(ax = axes["legend"], **kwargs) 726 if (self.legend_dx != 0 or self.legend_dx != 0): 727 box = axes["legend"].get_position() 728 box.x0 = box.x0 + self.legend_dx 729 box.x1 = box.x1 + self.legend_dx 730 box.y0 = box.y0 + self.legend_dy 731 box.y1 = box.y1 + self.legend_dy 732 axes["legend"].set_position(box) 733 else: 734 axes.set_axis_off() 735 axes.set_xlim(bb[0], bb[2]) 736 axes.set_ylim(bb[1], bb[3]) 737 self._plot_subsetted_gdf(axes, self.map, **kwargs) 738 return None 739 740 741 def _plot_subsetted_gdf(self, ax:pyplot.Axes, 742 gdf:gpd.GeoDataFrame, **kwargs) -> None: 743 """Plots a gpd.GeoDataFrame multiple times based on a subsetting 744 attribute (assumed to be "tile_id"). 745 746 NOTE: used to plot both the main map _and_ the legend. 747 748 Args: 749 ax (pyplot.Axes): axes to plot to. 750 gdf (gpd.GeoDataFrame): the GeoDataFrame to plot. 751 752 Raises: 753 Exception: if self.colourmaps cannot be parsed exception is raised. 754 """ 755 groups = gdf.groupby("tile_id") 756 for id, var in self.variables.items(): 757 subset = groups.get_group(id) 758 # Handle custom color assignments via 'cmaps' parameter. 759 # Result is setting 'cmap' variable used in plot command afterwards. 760 if (isinstance(self.colourmaps[var], dict)): 761 colormap_dict = self.colourmaps[var] 762 data_unique_sorted = sorted(subset[var].unique()) 763 cmap = matplotlib.colors.ListedColormap( 764 [colormap_dict[x] for x in data_unique_sorted]) 765 subset.plot(ax = ax, column = var, cmap = cmap, **kwargs) 766 else: 767 if (isinstance(self.colourmaps, 768 (str, matplotlib.colors.Colormap, 769 matplotlib.colors.LinearSegmentedColormap, 770 matplotlib.colors.ListedColormap))): 771 cmap = self.colourmaps # one palette for all ids 772 elif (len(self.colourmaps) == 0): 773 cmap = 'Reds' # set a default... here, to Brewer's 'Reds' 774 elif (var not in self.colourmaps): 775 cmap = 'Reds' # no color specified in dict, use default 776 elif (isinstance(self.colourmaps[var], 777 (str, matplotlib.colors.Colormap, 778 matplotlib.colors.LinearSegmentedColormap, 779 matplotlib.colors.ListedColormap))): 780 cmap = self.colourmaps[var] # specified colors for this var 781 else: 782 raise Exception(f"""Color map for '{var}' is not a known 783 type, but is {str(type(self.colourmaps[var]))}""") 784 785 subset.plot(ax = ax, column = var, cmap = cmap, 786 scheme = self.scheme, k = self.k, **kwargs) 787 788 789 def to_file(self, fname:str = None) -> None: 790 """Outputs the tiled map to a layered GPKG file. 791 792 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 793 794 Args: 795 fname (str, optional): Filename to write. Defaults to None. 796 """ 797 tiling_utils.write_map_to_layers(self.map, fname) 798 return None 799 800 801 def plot_legend(self, ax: pyplot.Axes = None, **kwargs) -> None: 802 """Plots a legend for this tiled map. 803 804 Args: 805 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 806 """ 807 # turn off axes (which seems also to make it impossible 808 # to set a background colour) 809 ax.set_axis_off() 810 811 legend_tiles = self.tiling.tile_unit._get_legend_tiles() 812 # this is a bit hacky, but we will apply the rotation to text 813 # annotation so for TileUnits which don't need it, reverse that now 814 if isinstance(self.tiling.tile_unit, TileUnit): 815 legend_tiles.rotation = -self.tiling.rotation 816 817 legend_key = self._get_legend_key_gdf(legend_tiles) 818 819 legend_tiles.geometry = legend_tiles.geometry.rotate( 820 self.tiling.rotation, origin = (0, 0)) 821 822 if self.use_ellipse: 823 ellipse = tiling_utils.get_bounding_ellipse( 824 legend_tiles.geometry, mag = self.ellipse_magnification) 825 bb = ellipse.total_bounds 826 c = ellipse.unary_union.centroid 827 else: 828 bb = legend_tiles.geometry.total_bounds 829 c = legend_tiles.geometry.unary_union.centroid 830 831 # apply legend zoom - NOTE that this must be applied even 832 # if self.legend_zoom is not == 1... 833 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 834 c.x + (bb[2] - c.x) / self.legend_zoom) 835 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 836 c.y + (bb[3] - c.y) / self.legend_zoom) 837 838 # plot the legend key tiles (which include the data) 839 self._plot_subsetted_gdf(ax, legend_key, lw = 0, **kwargs) 840 841 for id, tile, rotn in zip(self.variables.keys(), 842 legend_tiles.geometry, 843 legend_tiles.rotation): 844 c = tile.centroid 845 ax.annotate(self.variables[id], xy = (c.x, c.y), 846 ha = "center", va = "center", rotation_mode = "anchor", 847 # adjust rotation to favour text reading left to right 848 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 849 bbox = {"lw": 0, "fc": "#ffffff40"}) 850 851 # now plot background; we include the central tiles, since in 852 # the weave case these may not match the legend tiles 853 context_tiles = self.tiling.tile_unit.get_local_patch(r = 2, 854 include_0 = True).geometry.rotate(self.tiling.rotation, origin = (0, 0)) 855 # for reasons escaping all reason... invalid polygons sometimes show up 856 # here I think because of the rotation /shrug... in any case, this 857 # sledgehammer should fix it 858 # context_tiles = gpd.GeoSeries([g.simplify(1e-6) 859 # for g in context_tiles.geometry], 860 # crs = self.tiling.tile_unit.crs) 861 862 if self.use_ellipse: 863 context_tiles.clip(ellipse, keep_geom_type = False).plot( 864 ax = ax, fc = "#9F9F9F3F", lw = 0.0) 865 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 866 ellipse, keep_geom_type = True).plot(ax = ax, ec = "#5F5F5F", lw = 1) 867 else: 868 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", ec = "#5F5F5F", lw = 0.0) 869 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 870 ax = ax, ec = "#5F5F5F", lw = 1) 871 872 873 def _get_legend_key_gdf(self, tiles:gpd.GeoDataFrame) -> gpd.GeoDataFrame: 874 """Returns a GeoDataFrame of tiles dissected and with data assigned 875 to the slice so a map of them can stand as a legend. 876 877 'Dissection' is handled differently by `WeaveUnit` and `TileUnit` 878 objects and delegated to either `WeaveUnit._get_legend_key_shapes()` 879 or `TileUnit._get_legend_key_shapes()`. 880 881 Args: 882 tiles (gpd.GeoDataFrame): the legend tiles. 883 884 Returns: 885 gpd.GeoDataFrame: with tile_id, variables and rotation 886 attributes, and geometries of Tileable tiles sliced into a 887 colour ramp or set of nested tiles. 888 """ 889 key_tiles = [] # set of tiles to form a colour key (e.g. a ramp) 890 ids = [] # tile_ids applied to the keys 891 unique_ids = [] # list of each tile_id used in order 892 vals = [] # the data assigned to the key tiles 893 rots = [] # rotation of each key tile 894 subsets = self.map.groupby("tile_id") 895 for (id, var), geom, rot in zip(self.variables.items(), 896 tiles.geometry, 897 tiles.rotation): 898 subset = subsets.get_group(id) 899 d = subset[var] 900 radial = False 901 # if the data are categorical then it's complicated... 902 if d.dtype == pd.CategoricalDtype: 903 radial = True and self.radial_key 904 # desired order of categorical variable is the 905 # color maps dictionary keys 906 cmap = self.colourmaps[var] 907 num_cats = len(cmap) 908 val_order = dict(zip(cmap.keys(), range(num_cats))) 909 # compile counts of each category 910 freqs = [0] * num_cats 911 for v in list(d): 912 freqs[val_order[v]] += 1 913 # make list of the categories containing appropriate 914 # counts of each in the order needed using a reverse lookup 915 data_vals = list(val_order.keys()) 916 data_vals = [data_vals[i] for i, f in enumerate(freqs) if f > 0] 917 else: # any other data is easy! 918 data_vals = sorted(d) 919 freqs = [1] * len(data_vals) 920 key = self.tiling.tile_unit._get_legend_key_shapes( 921 geom, freqs, rot, radial) 922 key_tiles.extend(key) 923 vals.extend(data_vals) 924 n = len(data_vals) 925 ids.extend([id] * n) 926 unique_ids.append(id) 927 rots.extend([rot] * n) 928 # finally make up a data table with all the data in all the 929 # columns (each set of data only gets used in the subset it 930 # applies to). This allows us to reuse the tiling_utils. 931 # plot_subsetted_gdf() function 932 key_data = {} 933 for id in unique_ids: 934 key_data[self.variables[id]] = vals 935 936 key_gdf = gpd.GeoDataFrame( 937 data = key_data | {"tile_id": ids, "rotation": rots}, 938 crs = self.map.crs, 939 geometry = gpd.GeoSeries(key_tiles)) 940 key_gdf.geometry = key_gdf.rotate(self.tiling.rotation, origin = (0, 0)) 941 return key_gdf 942 943 944 def explore(self) -> None: 945 """TODO: add wrapper to make tiled web map via geopandas.explore. 946 """ 947 return None
166@dataclass 167class Tiling: 168 """Class that applies a `Tileable` object to a region to be mapped. 169 170 The result of the tiling procedure is stored in the `tiles` variable and 171 covers a region sufficient that the tiling can be rotated to any desired 172 angle. 173 """ 174 tile_unit:Tileable = None 175 """tileable on which the tiling is based.""" 176 tile_shape:TileShape = None 177 """base shape of the tileable.""" 178 region:gpd.GeoDataFrame = None 179 """the region to be tiled.""" 180 region_union: geom.Polygon = None 181 grid:_TileGrid = None 182 """the grid which will be used to apply the tiling.""" 183 tiles:gpd.GeoDataFrame = None 184 """the tiles after tiling has been carried out.""" 185 prototiles:gpd.GeoDataFrame = None 186 """the prototiles after tiling has been carried out.""" 187 rotation:float = 0.0 188 """the cumulative rotation already applied to the tiling.""" 189 190 def __init__(self, unit:Tileable, region:gpd.GeoDataFrame, id_var = None, 191 prototile_margin:float = 0, tiles_sf:float = 1, 192 tiles_margin:float = 0, as_icons:bool = False) -> None: 193 """Class to persist a tiling by filling an area relative to 194 a region sufficient to apply the tiling at any rotation. 195 196 The Tiling constructor allows a number of adjustments to the supplied 197 `weavingspace.tileable.Tileable` object: 198 199 + `prototile_margin` values greater than 0 will introduce spacing of 200 the specified distance between tiles on the boundary of each tile 201 by applying the `TileUnit.inset_prototile()` method. Note that this 202 operation does not make sense for `WeaveUnit` objects, 203 and may not preserve the equality of tile areas. 204 + `tiles_sf` values less than one scale down tiles by applying the 205 `TileUnit.scale_tiles()` method. Does not make sense for `WeaveUnit` 206 objects. 207 + `tiles_margin` values greater than one apply a negative buffer of 208 the specified distance to every tile in the tiling by applying the 209 `Tileable.inset_tiles()` method. This option is applicable to both 210 `WeaveUnit` and `TileUnit` objects. 211 212 Args: 213 unit (Tileable): the tile_unit to use. 214 region (gpd.GeoDataFrame): the region to be tiled. 215 prototile_margin (float, optional): values greater than 0 apply an 216 inset margin to the tile unit. Defaults to 0. 217 tiles_sf (float, optional): scales the tiles. Defaults to 1. 218 tiles_margin (float, optional): applies a negative buffer to 219 the tiles. Defaults to 0. 220 as_icons (bool, optional): if True prototiles will only be placed at 221 the region's zone centroids, one per zone. Defaults to 222 False. 223 """ 224 self.tile_unit = unit 225 self.rotation = self.tile_unit.rotation 226 if tiles_margin > 0: 227 self.tile_unit = self.tile_unit.inset_tiles(tiles_margin) 228 if tiles_sf != 1: 229 if isinstance(self.tile_unit, TileUnit): 230 self.tile_unit = self.tile_unit.scale_tiles(tiles_sf) 231 else: 232 print(f"""Applying scaling to tiles of a WeaveUnit does not make sense. 233 Ignoring tiles_sf setting of {tiles_sf}.""") 234 if prototile_margin > 0: 235 if isinstance(self.tile_unit, TileUnit): 236 self.tile_unit = self.tile_unit.inset_prototile(prototile_margin) 237 else: 238 print(f"""Applying a prototile margin to a WeaveUnit does 239 not make sense. Ignoring prototile_margin setting of 240 {prototile_margin}.""") 241 self.region = region 242 self.region.sindex 243 self.region_union = self.region.geometry.unary_union 244 if id_var != None: 245 print("""id_var is no longer required and will be deprecated soon. 246 A temporary unique index attribute is added and removed when 247 generating the tiled map.""") 248 if as_icons: 249 self.grid = _TileGrid(self.tile_unit, self.region.geometry, True) 250 else: 251 self.grid = _TileGrid(self.tile_unit, self.region.geometry) 252 self.tiles, self.prototiles = self.make_tiling() 253 self.tiles.sindex 254 255 256 def get_tiled_map(self, rotation:float = 0., 257 join_on_prototiles:bool = True, 258 prioritise_tiles:bool = True, 259 ragged_edges:bool = True, 260 use_centroid_lookup_approximation = False, 261 debug = False) -> "TiledMap": 262 """Returns a `TiledMap` filling a region at the requested rotation. 263 264 HERE BE DRAGONS! This function took a lot of trial and error to get 265 right, so modify with CAUTION! 266 267 The `proritise_tiles = True` option means that the tiling will not 268 break up the tiles in `TileUnit`s at the boundaries between areas 269 in the mapped region, but will instead ensure that tiles remain 270 complete, picking up their data from the region zone which they overlap 271 the most. 272 273 The exact order in which operations are performed affects performance. 274 For example, the final clipping to self.region when ragged_edges = 275 False is _much_ slower if it is carried out before the dissolving of 276 tiles into the region zones. So... again... modify CAREFULLY! 277 278 Args: 279 rotation (float, optional): An optional rotation to apply. Defaults 280 to 0. 281 join_on_prototiles (bool, optional): if True data from the region 282 dataset are joined to tiles based on the prototile to which they 283 belong. If False the join is based on the tiles in relation to the 284 region areas. For weave-based tilings False is probably to be 285 preferred. Defaults to True. 286 prioritise_tiles (bool, optional): if True tiles will not be 287 broken at boundaries in the region dataset. Defaults to True. 288 ragged_edges (bool, optional): if True tiles at the edge of the 289 region will not be cut by the region extent - ignored if 290 prioritise_tiles is False when edges will always be clipped to 291 the region extent. Defaults to True. 292 use_centroid_lookup_approximation (bool, optional): if True use 293 tile centroids for lookup of region data - ignored if 294 prioritise_tiles is False when it is irrelevant. Defaults to 295 False. 296 debug (bool, optional): if True prints timing messages. Defaults 297 to False. 298 299 Returns: 300 TiledMap: a TiledMap of the source region. 301 """ 302 if debug: 303 t1 = perf_counter() 304 305 id_var = self._setup_region_DZID() 306 if join_on_prototiles: 307 tiled_map, join_layer = self.rotated(rotation) 308 tiled_map["joinUID"] = self.tiles["prototile_id"] 309 else: 310 tiled_map = self.rotated(rotation)[0] 311 tiled_map["joinUID"] = self.tiles["tile_id"] 312 join_layer = tiled_map 313 join_layer["joinUID"] = list(range(join_layer.shape[0])) 314 315 # compile a list of the variable names we are NOT going to change 316 # i.e. everything except the geometry and the id_var 317 region_vars = list(self.region.columns) 318 region_vars.remove("geometry") 319 region_vars.remove(id_var) 320 321 if debug: 322 t2 = perf_counter() 323 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 324 325 if prioritise_tiles: # maintain tile continuity across zone boundaries 326 # select only tiles inside a spacing buffer of the region 327 # make column with unique ID for every tile in the tiling 328 # the join ID is unique per tile 329 # if join_on_prototiles: 330 # tiled_map["joinUID"] = self.tiles["prototile_id"] 331 # else: 332 # tiled_map["joinUID"] = self.tiles["tile_id"] 333 334 if use_centroid_lookup_approximation: 335 t5 = perf_counter() 336 tile_pts = copy.deepcopy(join_layer) 337 tile_pts.geometry = tile_pts.centroid 338 lookup = tile_pts.sjoin( 339 self.region, how = "inner")[["joinUID", id_var]] 340 else: 341 # determine areas of overlapping tiles and drop the data we join the 342 # data back later, so dropping makes that easier overlaying in region. 343 # overlay(tiles) seems to be faster?? 344 # TODO: also... this part is performance-critical, think about fixes -- 345 # possibly including the above centroid-based approx 346 overlaps = self.region.overlay(join_layer, make_valid = False) 347 # overlaps = self.region.overlay(tiled_map, make_valid = False) 348 if debug: 349 t3 = perf_counter() 350 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 351 overlaps["area"] = overlaps.geometry.area 352 if debug: 353 t4 = perf_counter() 354 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 355 overlaps.drop(columns = region_vars, inplace = True) 356 if debug: 357 t5 = perf_counter() 358 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 359 # make a lookup by largest area tile to region id 360 lookup = overlaps \ 361 .iloc[overlaps.groupby("joinUID")["area"] \ 362 .agg(pd.Series.idxmax)][["joinUID", id_var]] 363 # now join the lookup and from there the region data 364 if debug: 365 t6 = perf_counter() 366 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 367 tiled_map = tiled_map \ 368 .merge(lookup, on = "joinUID") \ 369 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 370 if debug: 371 t7 = perf_counter() 372 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 373 tiled_map.drop(columns = ["joinUID"], inplace = True) 374 375 else: # here we overlay 376 tiled_map = self.region.overlay(tiled_map) 377 t7 = perf_counter() 378 if debug: 379 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 380 381 if join_on_prototiles: 382 tiled_map = tiled_map.loc[ 383 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 384 385 tiled_map.drop(columns = [id_var], inplace = True) 386 self.region.drop(columns = [id_var], inplace = True) 387 388 # if we've retained tiles and want 'clean' edges, then clip 389 # note that this step is slow: geopandas unary_unions the clip layer 390 if prioritise_tiles and not ragged_edges: 391 tiled_map.sindex 392 tiled_map = tiled_map.clip(self.region) 393 if debug: 394 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 395 396 tm = TiledMap() 397 tm.tiling = self 398 tm.map = tiled_map 399 return tm 400 401 402 def _setup_region_DZID(self) -> str: 403 """Creates a new guaranteed-unique attribute in the self.region 404 dataframe, and returns its name. 405 406 Avoids a name clash with any existing attribute in the dataframe. 407 408 Returns: 409 str: name of the added attribute. 410 """ 411 dzid = "DZID" 412 i = 0 413 while dzid in self.region.columns: 414 dzid = "DZID" + str(i) 415 i = i + 1 416 self.region[dzid] = list(range(self.region.shape[0])) 417 return dzid 418 419 420 def _rotate_gdf_to_geoseries( 421 self, gdf:gpd.GeoDataFrame, 422 angle:float, centre:tuple = (0, 0) 423 ) -> tuple[gpd.GeoSeries, tuple[float]]: 424 """Rotates the geometries in a GeoDataFrame as a single collection. 425 426 Rotation is about the supplied centre or about the centroid of the 427 GeoDataFrame (if not). This allows for reversal of a rotation. [Note 428 that this might not be a required precaution!] 429 430 Args: 431 gdf (geopandas.GeoDataFrame): GeoDataFrame to rotate 432 angle (float): angle of rotation (degrees). 433 centre (tuple, optional): desired centre of rotation. Defaults 434 to (0, 0). 435 436 Returns: 437 tuple: a geopandas.GeoSeries and a tuple (point) of the centre of 438 the rotation. 439 """ 440 centre = ( 441 gdf.geometry.unary_union.centroid.coords[0] 442 if centre is None 443 else centre) 444 return gdf.geometry.rotate(angle, origin = centre), centre 445 446 447 def make_tiling(self) -> gpd.GeoDataFrame: 448 """Tiles the region with a tile unit, returning a GeoDataFrame 449 450 Returns: 451 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 452 tile unit. 453 """ 454 # we assume the geometry column is called geometry so make it so... 455 if self.region.geometry.name != "geometry": 456 self.region.rename_geometry("geometry", inplace = True) 457 458 # chain list of lists of GeoSeries geometries to list of geometries 459 tiles = itertools.chain(*[ 460 self.tile_unit.tiles.geometry.translate(p.x, p.y) 461 for p in self.grid.points]) 462 prototiles = itertools.chain(*[ 463 self.tile_unit.prototile.geometry.translate(p.x, p.y) 464 for p in self.grid.points]) 465 # replicate the tile ids 466 prototile_ids = list(range(len(self.grid.points))) 467 tile_ids = list(self.tile_unit.tiles.tile_id) * len(self.grid.points) 468 tile_prototile_ids = sorted(prototile_ids * self.tile_unit.tiles.shape[0]) 469 tiles_gs = gpd.GeoSeries(tiles) 470 prototiles_gs = gpd.GeoSeries(prototiles) 471 # assemble and return as GeoDataFrames 472 tiles_gdf = gpd.GeoDataFrame( 473 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 474 geometry = tiles_gs, crs = self.tile_unit.crs) 475 prototiles_gdf = gpd.GeoDataFrame( 476 data = {"prototile_id": prototile_ids}, 477 geometry = prototiles_gs, crs = self.tile_unit.crs) 478 # unclear if we need the gridify or not... 479 return (tiling_utils.gridify(tiles_gdf), 480 tiling_utils.gridify(prototiles_gdf)) 481 482 483 def rotated(self, rotation:float = None) -> gpd.GeoDataFrame: 484 """Returns the stored tiling rotated. 485 486 Args: 487 rotation (float, optional): Rotation angle in degrees. 488 Defaults to None. 489 490 Returns: 491 gpd.GeoDataFrame: Rotated tiling. 492 """ 493 if self.tiles is None: 494 self.tiles = self.make_tiling() 495 self.rotation = rotation 496 if self.rotation == 0: 497 return self.tiles, self.prototiles 498 tiles = gpd.GeoDataFrame( 499 data = {"tile_id": self.tiles.tile_id, 500 "prototile_id": self.tiles.tile_id}, 501 crs = self.tiles.crs, 502 geometry = tiling_utils.gridify( 503 self.tiles.geometry.rotate(rotation, origin = self.grid.centre))) 504 prototiles = gpd.GeoDataFrame( 505 data = {"prototile_id": self.prototiles.prototile_id}, 506 crs = self.prototiles.crs, 507 geometry = tiling_utils.gridify( 508 self.prototiles.geometry.rotate(rotation, origin = self.grid.centre))) 509 return tiles, prototiles
Class that applies a Tileable
object to a region to be mapped.
The result of the tiling procedure is stored in the tiles
variable and
covers a region sufficient that the tiling can be rotated to any desired
angle.
190 def __init__(self, unit:Tileable, region:gpd.GeoDataFrame, id_var = None, 191 prototile_margin:float = 0, tiles_sf:float = 1, 192 tiles_margin:float = 0, as_icons:bool = False) -> None: 193 """Class to persist a tiling by filling an area relative to 194 a region sufficient to apply the tiling at any rotation. 195 196 The Tiling constructor allows a number of adjustments to the supplied 197 `weavingspace.tileable.Tileable` object: 198 199 + `prototile_margin` values greater than 0 will introduce spacing of 200 the specified distance between tiles on the boundary of each tile 201 by applying the `TileUnit.inset_prototile()` method. Note that this 202 operation does not make sense for `WeaveUnit` objects, 203 and may not preserve the equality of tile areas. 204 + `tiles_sf` values less than one scale down tiles by applying the 205 `TileUnit.scale_tiles()` method. Does not make sense for `WeaveUnit` 206 objects. 207 + `tiles_margin` values greater than one apply a negative buffer of 208 the specified distance to every tile in the tiling by applying the 209 `Tileable.inset_tiles()` method. This option is applicable to both 210 `WeaveUnit` and `TileUnit` objects. 211 212 Args: 213 unit (Tileable): the tile_unit to use. 214 region (gpd.GeoDataFrame): the region to be tiled. 215 prototile_margin (float, optional): values greater than 0 apply an 216 inset margin to the tile unit. Defaults to 0. 217 tiles_sf (float, optional): scales the tiles. Defaults to 1. 218 tiles_margin (float, optional): applies a negative buffer to 219 the tiles. Defaults to 0. 220 as_icons (bool, optional): if True prototiles will only be placed at 221 the region's zone centroids, one per zone. Defaults to 222 False. 223 """ 224 self.tile_unit = unit 225 self.rotation = self.tile_unit.rotation 226 if tiles_margin > 0: 227 self.tile_unit = self.tile_unit.inset_tiles(tiles_margin) 228 if tiles_sf != 1: 229 if isinstance(self.tile_unit, TileUnit): 230 self.tile_unit = self.tile_unit.scale_tiles(tiles_sf) 231 else: 232 print(f"""Applying scaling to tiles of a WeaveUnit does not make sense. 233 Ignoring tiles_sf setting of {tiles_sf}.""") 234 if prototile_margin > 0: 235 if isinstance(self.tile_unit, TileUnit): 236 self.tile_unit = self.tile_unit.inset_prototile(prototile_margin) 237 else: 238 print(f"""Applying a prototile margin to a WeaveUnit does 239 not make sense. Ignoring prototile_margin setting of 240 {prototile_margin}.""") 241 self.region = region 242 self.region.sindex 243 self.region_union = self.region.geometry.unary_union 244 if id_var != None: 245 print("""id_var is no longer required and will be deprecated soon. 246 A temporary unique index attribute is added and removed when 247 generating the tiled map.""") 248 if as_icons: 249 self.grid = _TileGrid(self.tile_unit, self.region.geometry, True) 250 else: 251 self.grid = _TileGrid(self.tile_unit, self.region.geometry) 252 self.tiles, self.prototiles = self.make_tiling() 253 self.tiles.sindex
Class to persist a tiling by filling an area relative to a region sufficient to apply the tiling at any rotation.
The Tiling constructor allows a number of adjustments to the supplied
weavingspace.tileable.Tileable
object:
prototile_margin
values greater than 0 will introduce spacing of the specified distance between tiles on the boundary of each tile by applying theTileUnit.inset_prototile()
method. Note that this operation does not make sense forWeaveUnit
objects, and may not preserve the equality of tile areas.tiles_sf
values less than one scale down tiles by applying theTileUnit.scale_tiles()
method. Does not make sense forWeaveUnit
objects.tiles_margin
values greater than one apply a negative buffer of the specified distance to every tile in the tiling by applying theTileable.inset_tiles()
method. This option is applicable to bothWeaveUnit
andTileUnit
objects.
Args: unit (Tileable): the tile_unit to use. region (gpd.GeoDataFrame): the region to be tiled. prototile_margin (float, optional): values greater than 0 apply an inset margin to the tile unit. Defaults to 0. tiles_sf (float, optional): scales the tiles. Defaults to 1. tiles_margin (float, optional): applies a negative buffer to the tiles. Defaults to 0. as_icons (bool, optional): if True prototiles will only be placed at the region's zone centroids, one per zone. Defaults to False.
the prototiles after tiling has been carried out.
256 def get_tiled_map(self, rotation:float = 0., 257 join_on_prototiles:bool = True, 258 prioritise_tiles:bool = True, 259 ragged_edges:bool = True, 260 use_centroid_lookup_approximation = False, 261 debug = False) -> "TiledMap": 262 """Returns a `TiledMap` filling a region at the requested rotation. 263 264 HERE BE DRAGONS! This function took a lot of trial and error to get 265 right, so modify with CAUTION! 266 267 The `proritise_tiles = True` option means that the tiling will not 268 break up the tiles in `TileUnit`s at the boundaries between areas 269 in the mapped region, but will instead ensure that tiles remain 270 complete, picking up their data from the region zone which they overlap 271 the most. 272 273 The exact order in which operations are performed affects performance. 274 For example, the final clipping to self.region when ragged_edges = 275 False is _much_ slower if it is carried out before the dissolving of 276 tiles into the region zones. So... again... modify CAREFULLY! 277 278 Args: 279 rotation (float, optional): An optional rotation to apply. Defaults 280 to 0. 281 join_on_prototiles (bool, optional): if True data from the region 282 dataset are joined to tiles based on the prototile to which they 283 belong. If False the join is based on the tiles in relation to the 284 region areas. For weave-based tilings False is probably to be 285 preferred. Defaults to True. 286 prioritise_tiles (bool, optional): if True tiles will not be 287 broken at boundaries in the region dataset. Defaults to True. 288 ragged_edges (bool, optional): if True tiles at the edge of the 289 region will not be cut by the region extent - ignored if 290 prioritise_tiles is False when edges will always be clipped to 291 the region extent. Defaults to True. 292 use_centroid_lookup_approximation (bool, optional): if True use 293 tile centroids for lookup of region data - ignored if 294 prioritise_tiles is False when it is irrelevant. Defaults to 295 False. 296 debug (bool, optional): if True prints timing messages. Defaults 297 to False. 298 299 Returns: 300 TiledMap: a TiledMap of the source region. 301 """ 302 if debug: 303 t1 = perf_counter() 304 305 id_var = self._setup_region_DZID() 306 if join_on_prototiles: 307 tiled_map, join_layer = self.rotated(rotation) 308 tiled_map["joinUID"] = self.tiles["prototile_id"] 309 else: 310 tiled_map = self.rotated(rotation)[0] 311 tiled_map["joinUID"] = self.tiles["tile_id"] 312 join_layer = tiled_map 313 join_layer["joinUID"] = list(range(join_layer.shape[0])) 314 315 # compile a list of the variable names we are NOT going to change 316 # i.e. everything except the geometry and the id_var 317 region_vars = list(self.region.columns) 318 region_vars.remove("geometry") 319 region_vars.remove(id_var) 320 321 if debug: 322 t2 = perf_counter() 323 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 324 325 if prioritise_tiles: # maintain tile continuity across zone boundaries 326 # select only tiles inside a spacing buffer of the region 327 # make column with unique ID for every tile in the tiling 328 # the join ID is unique per tile 329 # if join_on_prototiles: 330 # tiled_map["joinUID"] = self.tiles["prototile_id"] 331 # else: 332 # tiled_map["joinUID"] = self.tiles["tile_id"] 333 334 if use_centroid_lookup_approximation: 335 t5 = perf_counter() 336 tile_pts = copy.deepcopy(join_layer) 337 tile_pts.geometry = tile_pts.centroid 338 lookup = tile_pts.sjoin( 339 self.region, how = "inner")[["joinUID", id_var]] 340 else: 341 # determine areas of overlapping tiles and drop the data we join the 342 # data back later, so dropping makes that easier overlaying in region. 343 # overlay(tiles) seems to be faster?? 344 # TODO: also... this part is performance-critical, think about fixes -- 345 # possibly including the above centroid-based approx 346 overlaps = self.region.overlay(join_layer, make_valid = False) 347 # overlaps = self.region.overlay(tiled_map, make_valid = False) 348 if debug: 349 t3 = perf_counter() 350 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 351 overlaps["area"] = overlaps.geometry.area 352 if debug: 353 t4 = perf_counter() 354 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 355 overlaps.drop(columns = region_vars, inplace = True) 356 if debug: 357 t5 = perf_counter() 358 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 359 # make a lookup by largest area tile to region id 360 lookup = overlaps \ 361 .iloc[overlaps.groupby("joinUID")["area"] \ 362 .agg(pd.Series.idxmax)][["joinUID", id_var]] 363 # now join the lookup and from there the region data 364 if debug: 365 t6 = perf_counter() 366 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 367 tiled_map = tiled_map \ 368 .merge(lookup, on = "joinUID") \ 369 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 370 if debug: 371 t7 = perf_counter() 372 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 373 tiled_map.drop(columns = ["joinUID"], inplace = True) 374 375 else: # here we overlay 376 tiled_map = self.region.overlay(tiled_map) 377 t7 = perf_counter() 378 if debug: 379 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 380 381 if join_on_prototiles: 382 tiled_map = tiled_map.loc[ 383 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 384 385 tiled_map.drop(columns = [id_var], inplace = True) 386 self.region.drop(columns = [id_var], inplace = True) 387 388 # if we've retained tiles and want 'clean' edges, then clip 389 # note that this step is slow: geopandas unary_unions the clip layer 390 if prioritise_tiles and not ragged_edges: 391 tiled_map.sindex 392 tiled_map = tiled_map.clip(self.region) 393 if debug: 394 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 395 396 tm = TiledMap() 397 tm.tiling = self 398 tm.map = tiled_map 399 return tm
Returns a TiledMap
filling a region at the requested rotation.
HERE BE DRAGONS! This function took a lot of trial and error to get right, so modify with CAUTION!
The proritise_tiles = True
option means that the tiling will not
break up the tiles in TileUnit
s at the boundaries between areas
in the mapped region, but will instead ensure that tiles remain
complete, picking up their data from the region zone which they overlap
the most.
The exact order in which operations are performed affects performance. For example, the final clipping to self.region when ragged_edges = False is _much_ slower if it is carried out before the dissolving of tiles into the region zones. So... again... modify CAREFULLY!
Args: rotation (float, optional): An optional rotation to apply. Defaults to 0. join_on_prototiles (bool, optional): if True data from the region dataset are joined to tiles based on the prototile to which they belong. If False the join is based on the tiles in relation to the region areas. For weave-based tilings False is probably to be preferred. Defaults to True. prioritise_tiles (bool, optional): if True tiles will not be broken at boundaries in the region dataset. Defaults to True. ragged_edges (bool, optional): if True tiles at the edge of the region will not be cut by the region extent - ignored if prioritise_tiles is False when edges will always be clipped to the region extent. Defaults to True. use_centroid_lookup_approximation (bool, optional): if True use tile centroids for lookup of region data - ignored if prioritise_tiles is False when it is irrelevant. Defaults to False. debug (bool, optional): if True prints timing messages. Defaults to False.
Returns: TiledMap: a TiledMap of the source region.
447 def make_tiling(self) -> gpd.GeoDataFrame: 448 """Tiles the region with a tile unit, returning a GeoDataFrame 449 450 Returns: 451 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 452 tile unit. 453 """ 454 # we assume the geometry column is called geometry so make it so... 455 if self.region.geometry.name != "geometry": 456 self.region.rename_geometry("geometry", inplace = True) 457 458 # chain list of lists of GeoSeries geometries to list of geometries 459 tiles = itertools.chain(*[ 460 self.tile_unit.tiles.geometry.translate(p.x, p.y) 461 for p in self.grid.points]) 462 prototiles = itertools.chain(*[ 463 self.tile_unit.prototile.geometry.translate(p.x, p.y) 464 for p in self.grid.points]) 465 # replicate the tile ids 466 prototile_ids = list(range(len(self.grid.points))) 467 tile_ids = list(self.tile_unit.tiles.tile_id) * len(self.grid.points) 468 tile_prototile_ids = sorted(prototile_ids * self.tile_unit.tiles.shape[0]) 469 tiles_gs = gpd.GeoSeries(tiles) 470 prototiles_gs = gpd.GeoSeries(prototiles) 471 # assemble and return as GeoDataFrames 472 tiles_gdf = gpd.GeoDataFrame( 473 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 474 geometry = tiles_gs, crs = self.tile_unit.crs) 475 prototiles_gdf = gpd.GeoDataFrame( 476 data = {"prototile_id": prototile_ids}, 477 geometry = prototiles_gs, crs = self.tile_unit.crs) 478 # unclear if we need the gridify or not... 479 return (tiling_utils.gridify(tiles_gdf), 480 tiling_utils.gridify(prototiles_gdf))
Tiles the region with a tile unit, returning a GeoDataFrame
Returns: geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the tile unit.
483 def rotated(self, rotation:float = None) -> gpd.GeoDataFrame: 484 """Returns the stored tiling rotated. 485 486 Args: 487 rotation (float, optional): Rotation angle in degrees. 488 Defaults to None. 489 490 Returns: 491 gpd.GeoDataFrame: Rotated tiling. 492 """ 493 if self.tiles is None: 494 self.tiles = self.make_tiling() 495 self.rotation = rotation 496 if self.rotation == 0: 497 return self.tiles, self.prototiles 498 tiles = gpd.GeoDataFrame( 499 data = {"tile_id": self.tiles.tile_id, 500 "prototile_id": self.tiles.tile_id}, 501 crs = self.tiles.crs, 502 geometry = tiling_utils.gridify( 503 self.tiles.geometry.rotate(rotation, origin = self.grid.centre))) 504 prototiles = gpd.GeoDataFrame( 505 data = {"prototile_id": self.prototiles.prototile_id}, 506 crs = self.prototiles.crs, 507 geometry = tiling_utils.gridify( 508 self.prototiles.geometry.rotate(rotation, origin = self.grid.centre))) 509 return tiles, prototiles
Returns the stored tiling rotated.
Args: rotation (float, optional): Rotation angle in degrees. Defaults to None.
Returns: gpd.GeoDataFrame: Rotated tiling.
512@dataclass 513class TiledMap: 514 """Class representing a tiled map. Should not be accessed directly, but 515 will be created by calling `Tiling.get_tiled_map()`. After creation the 516 variables and colourmaps attributes can be set, and then 517 `TiledMap.render()` called to make a map. Settable attributes are explained 518 in documentation of the `TiledMap.render()` method. 519 520 Examples: 521 Recommended usage is as follows. First, make a `TiledMap` from a `Tiling` object. 522 523 tm = tiling.get_tiled_map(...) 524 525 Some options in the `Tiling` constructor affect the map appearance. See 526 `Tiling` for details. 527 528 Once a `TiledMap` object exists, set options on it, either when calling 529 `TiledMap.render()` or explicitly, i.e. 530 531 tm.render(opt1 = val1, opt2 = val2, ...) 532 533 or 534 535 tm.opt1 = val1 536 tm.opt2 = val2 537 tm.render() 538 539 Option settings are persistent, i.e. unless a new `TiledMap` object is 540 created the option settings have to be explicitly reset to default 541 values on subsequent calls to `TiledMap.render()`. 542 543 The most important options are the `variables` and `colourmaps` 544 settings. 545 546 `variables` is a dictionary mapping `weavingspace.tileable.Tileable` 547 tile_ids (usually "a", "b", etc.) to variable names in the data. For 548 example, 549 550 tm.variables = dict(zip(["a", "b"], ["population", "income"])) 551 552 `colourmaps` is a dictionary mapping dataset variable names to the 553 matplotlib colourmap to be used for each. For example, 554 555 tm.colourmaps = dict(zip(tm.variables.values(), ["Reds", "Blues"])) 556 557 See [this notebook](https://github.com/DOSull/weaving-space/blob/main/weavingspace/example-tiles-cairo.ipynb) 558 for simple usage. 559 TODO: This more complicated example shows how categorical maps can be 560 created. 561 """ 562 # these will be set at instantion by Tiling.get_tiled_map() 563 tiling:Tiling = None 564 """the Tiling with the required tiles""" 565 map:gpd.GeoDataFrame = None 566 """the GeoDataFrame on which this map is based""" 567 variables:dict[str,str] = None 568 """lookup from tile_id to variable names""" 569 colourmaps:dict[str,Union[str,dict]] = None 570 """lookup from variables to matplotlib cmaps""" 571 572 # the below parameters can be set either before calling self.render() 573 # or passed in as parameters to self.render() 574 # these are solely TiledMap.render() options 575 legend:bool = True 576 """whether or not to show a legend""" 577 legend_zoom:float = 1.0 578 """<1 zooms out from legend to show more context""" 579 legend_dx:float = 0. 580 """x shift of legend relative to the map""" 581 legend_dy:float = 0. 582 """y shift of legend relative to the map""" 583 use_ellipse:bool = False 584 """if True clips legend with an ellipse""" 585 ellipse_magnification:float = 1.0 586 """magnification to apply to clip ellipse""" 587 radial_key:bool = False 588 """if True use radial key even for ordinal/ratio data (normally these will be 589 shown by concentric tile geometries)""" 590 draft_mode:bool = False 591 """if True plot the map coloured by tile_id""" 592 593 # the parameters below are geopandas.plot options which we intercept to 594 # ensure they are applied appropriately when we plot a GDF 595 scheme:str = "equalinterval" 596 """geopandas scheme to apply""" 597 k:int = 100 598 """geopandas number of classes to apply""" 599 figsize:tuple[float] = (20, 15) 600 """maptlotlib figsize""" 601 dpi:float = 72 602 """dpi for bitmap formats""" 603 604 def render(self, **kwargs) -> Figure: 605 """Renders the current state to a map. 606 607 Note that TiledMap objects will usually be created by calling 608 `Tiling.get_tiled_map()`. 609 610 Args: 611 variables (dict[str,str]): Mapping from tile_id values to 612 variable names. Defaults to None. 613 colourmaps (dict[str,Union[str,dict]]): Mapping from variable 614 names to colour map, either a colour palette as used by 615 geopandas/matplotlib, a fixed colour, or a dictionary mapping 616 categorical data values to colours. Defaults to None. 617 legend (bool): If True a legend will be drawn. Defaults to True. 618 legend_zoom (float): Zoom factor to apply to the legend. Values <1 619 will show more of the tile context. Defaults to 1.0. 620 legend_dx (float): x shift to apply to the legend position. 621 Defaults to 0.0. 622 legend_dy (float): x and y shift to apply to the legend position. 623 Defaults to 0.0. 624 use_ellipse (bool): If True applies an elliptical clip to the 625 legend. Defaults to False. 626 ellipse_magnification (float): Magnification to apply to ellipse 627 clipped legend. Defaults to 1.0. 628 radial_key (bool): If True legend key for TileUnit maps will be 629 based on radially dissecting the tiles. Defaults to False. 630 draft_mode (bool): If True a map of the tiled map coloured by 631 tile_ids (and with no legend) is returned. Defaults to False. 632 scheme (str): passed to geopandas.plot for numeric data. Defaults to 633 "equalinterval". 634 k (int): passed to geopandas.plot for numeric data. Defaults to 100. 635 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 636 plot. Defaults to (20,15). 637 dpi (float): passed to pyplot.plot. Defaults to 72. 638 **kwargs: other settings to pass to pyplot/geopandas.plot. 639 640 Returns: 641 matplotlib.figure.Figure: figure on which map is plotted. 642 """ 643 pyplot.rcParams['pdf.fonttype'] = 42 644 pyplot.rcParams['pdf.use14corefonts'] = True 645 matplotlib.rcParams['pdf.fonttype'] = 42 646 647 to_remove = set() # keep track of kwargs we use to setup TiledMap 648 for k, v in kwargs.items(): 649 if k in self.__dict__: 650 self.__dict__[k] = v 651 to_remove.add(k) 652 # remove them so we don't pass them on to pyplot and get errors 653 for k in to_remove: 654 del kwargs[k] 655 656 if self.draft_mode: 657 fig = pyplot.figure(figsize = self.figsize) 658 ax = fig.add_subplot(111) 659 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", 660 **kwargs) 661 return fig 662 663 if self.legend: 664 # this sizing stuff is rough and ready for now, possibly forever... 665 reg_w, reg_h, *_ = \ 666 tiling_utils.get_width_height_left_bottom(self.map.geometry) 667 tile_w, tile_h, *_ = \ 668 tiling_utils.get_width_height_left_bottom( 669 self.tiling.tile_unit._get_legend_tiles().rotate( 670 self.tiling.rotation, origin = (0, 0))) 671 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 672 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 673 "width_ratios": [reg_w, sf_w * tile_w]} 674 675 fig, axes = pyplot.subplot_mosaic( 676 [["map", "legend"], ["map", "."]], 677 gridspec_kw = gskw, figsize = self.figsize, 678 layout = "constrained", **kwargs) 679 else: 680 fig, axes = pyplot.subplots( 681 1, 1, figsize = self.figsize, 682 layout = "constrained", **kwargs) 683 684 if self.variables is None: 685 # get any floating point columns available 686 default_columns = \ 687 self.map.select_dtypes( 688 include = ("float64", "int64")).columns 689 self.variables = dict(zip(self.map.tile_id.unique(), 690 list(default_columns))) 691 print(f"""No variables specified, picked the first 692 {len(self.variables)} numeric ones available.""") 693 elif isinstance(self.variables, (list, tuple)): 694 self.variables = dict(zip( 695 self.tiling.tile_unit.tiles.tile_id.unique(), 696 self.variables)) 697 print(f"""Only a list of variables specified, assigning to 698 available tile_ids.""") 699 700 if self.colourmaps is None: 701 self.colourmaps = {} 702 for var in self.variables.values(): 703 if self.map[var].dtype == pd.CategoricalDtype: 704 self.colourmaps[var] = "tab20" 705 print(f"""For categorical data, you should specify colour 706 mapping explicitly.""") 707 else: 708 self.colourmaps[var] = "Reds" 709 710 self._plot_map(axes, **kwargs) 711 return fig 712 713 714 def _plot_map(self, axes:pyplot.Axes, **kwargs) -> None: 715 """Plots map to the supplied axes. 716 717 Args: 718 axes (pyplot.Axes): axes on which maps will be drawn. 719 """ 720 bb = self.map.geometry.total_bounds 721 if self.legend: 722 axes["map"].set_axis_off() 723 axes["map"].set_xlim(bb[0], bb[2]) 724 axes["map"].set_ylim(bb[1], bb[3]) 725 self._plot_subsetted_gdf(axes["map"], self.map, **kwargs) 726 self.plot_legend(ax = axes["legend"], **kwargs) 727 if (self.legend_dx != 0 or self.legend_dx != 0): 728 box = axes["legend"].get_position() 729 box.x0 = box.x0 + self.legend_dx 730 box.x1 = box.x1 + self.legend_dx 731 box.y0 = box.y0 + self.legend_dy 732 box.y1 = box.y1 + self.legend_dy 733 axes["legend"].set_position(box) 734 else: 735 axes.set_axis_off() 736 axes.set_xlim(bb[0], bb[2]) 737 axes.set_ylim(bb[1], bb[3]) 738 self._plot_subsetted_gdf(axes, self.map, **kwargs) 739 return None 740 741 742 def _plot_subsetted_gdf(self, ax:pyplot.Axes, 743 gdf:gpd.GeoDataFrame, **kwargs) -> None: 744 """Plots a gpd.GeoDataFrame multiple times based on a subsetting 745 attribute (assumed to be "tile_id"). 746 747 NOTE: used to plot both the main map _and_ the legend. 748 749 Args: 750 ax (pyplot.Axes): axes to plot to. 751 gdf (gpd.GeoDataFrame): the GeoDataFrame to plot. 752 753 Raises: 754 Exception: if self.colourmaps cannot be parsed exception is raised. 755 """ 756 groups = gdf.groupby("tile_id") 757 for id, var in self.variables.items(): 758 subset = groups.get_group(id) 759 # Handle custom color assignments via 'cmaps' parameter. 760 # Result is setting 'cmap' variable used in plot command afterwards. 761 if (isinstance(self.colourmaps[var], dict)): 762 colormap_dict = self.colourmaps[var] 763 data_unique_sorted = sorted(subset[var].unique()) 764 cmap = matplotlib.colors.ListedColormap( 765 [colormap_dict[x] for x in data_unique_sorted]) 766 subset.plot(ax = ax, column = var, cmap = cmap, **kwargs) 767 else: 768 if (isinstance(self.colourmaps, 769 (str, matplotlib.colors.Colormap, 770 matplotlib.colors.LinearSegmentedColormap, 771 matplotlib.colors.ListedColormap))): 772 cmap = self.colourmaps # one palette for all ids 773 elif (len(self.colourmaps) == 0): 774 cmap = 'Reds' # set a default... here, to Brewer's 'Reds' 775 elif (var not in self.colourmaps): 776 cmap = 'Reds' # no color specified in dict, use default 777 elif (isinstance(self.colourmaps[var], 778 (str, matplotlib.colors.Colormap, 779 matplotlib.colors.LinearSegmentedColormap, 780 matplotlib.colors.ListedColormap))): 781 cmap = self.colourmaps[var] # specified colors for this var 782 else: 783 raise Exception(f"""Color map for '{var}' is not a known 784 type, but is {str(type(self.colourmaps[var]))}""") 785 786 subset.plot(ax = ax, column = var, cmap = cmap, 787 scheme = self.scheme, k = self.k, **kwargs) 788 789 790 def to_file(self, fname:str = None) -> None: 791 """Outputs the tiled map to a layered GPKG file. 792 793 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 794 795 Args: 796 fname (str, optional): Filename to write. Defaults to None. 797 """ 798 tiling_utils.write_map_to_layers(self.map, fname) 799 return None 800 801 802 def plot_legend(self, ax: pyplot.Axes = None, **kwargs) -> None: 803 """Plots a legend for this tiled map. 804 805 Args: 806 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 807 """ 808 # turn off axes (which seems also to make it impossible 809 # to set a background colour) 810 ax.set_axis_off() 811 812 legend_tiles = self.tiling.tile_unit._get_legend_tiles() 813 # this is a bit hacky, but we will apply the rotation to text 814 # annotation so for TileUnits which don't need it, reverse that now 815 if isinstance(self.tiling.tile_unit, TileUnit): 816 legend_tiles.rotation = -self.tiling.rotation 817 818 legend_key = self._get_legend_key_gdf(legend_tiles) 819 820 legend_tiles.geometry = legend_tiles.geometry.rotate( 821 self.tiling.rotation, origin = (0, 0)) 822 823 if self.use_ellipse: 824 ellipse = tiling_utils.get_bounding_ellipse( 825 legend_tiles.geometry, mag = self.ellipse_magnification) 826 bb = ellipse.total_bounds 827 c = ellipse.unary_union.centroid 828 else: 829 bb = legend_tiles.geometry.total_bounds 830 c = legend_tiles.geometry.unary_union.centroid 831 832 # apply legend zoom - NOTE that this must be applied even 833 # if self.legend_zoom is not == 1... 834 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 835 c.x + (bb[2] - c.x) / self.legend_zoom) 836 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 837 c.y + (bb[3] - c.y) / self.legend_zoom) 838 839 # plot the legend key tiles (which include the data) 840 self._plot_subsetted_gdf(ax, legend_key, lw = 0, **kwargs) 841 842 for id, tile, rotn in zip(self.variables.keys(), 843 legend_tiles.geometry, 844 legend_tiles.rotation): 845 c = tile.centroid 846 ax.annotate(self.variables[id], xy = (c.x, c.y), 847 ha = "center", va = "center", rotation_mode = "anchor", 848 # adjust rotation to favour text reading left to right 849 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 850 bbox = {"lw": 0, "fc": "#ffffff40"}) 851 852 # now plot background; we include the central tiles, since in 853 # the weave case these may not match the legend tiles 854 context_tiles = self.tiling.tile_unit.get_local_patch(r = 2, 855 include_0 = True).geometry.rotate(self.tiling.rotation, origin = (0, 0)) 856 # for reasons escaping all reason... invalid polygons sometimes show up 857 # here I think because of the rotation /shrug... in any case, this 858 # sledgehammer should fix it 859 # context_tiles = gpd.GeoSeries([g.simplify(1e-6) 860 # for g in context_tiles.geometry], 861 # crs = self.tiling.tile_unit.crs) 862 863 if self.use_ellipse: 864 context_tiles.clip(ellipse, keep_geom_type = False).plot( 865 ax = ax, fc = "#9F9F9F3F", lw = 0.0) 866 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 867 ellipse, keep_geom_type = True).plot(ax = ax, ec = "#5F5F5F", lw = 1) 868 else: 869 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", ec = "#5F5F5F", lw = 0.0) 870 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 871 ax = ax, ec = "#5F5F5F", lw = 1) 872 873 874 def _get_legend_key_gdf(self, tiles:gpd.GeoDataFrame) -> gpd.GeoDataFrame: 875 """Returns a GeoDataFrame of tiles dissected and with data assigned 876 to the slice so a map of them can stand as a legend. 877 878 'Dissection' is handled differently by `WeaveUnit` and `TileUnit` 879 objects and delegated to either `WeaveUnit._get_legend_key_shapes()` 880 or `TileUnit._get_legend_key_shapes()`. 881 882 Args: 883 tiles (gpd.GeoDataFrame): the legend tiles. 884 885 Returns: 886 gpd.GeoDataFrame: with tile_id, variables and rotation 887 attributes, and geometries of Tileable tiles sliced into a 888 colour ramp or set of nested tiles. 889 """ 890 key_tiles = [] # set of tiles to form a colour key (e.g. a ramp) 891 ids = [] # tile_ids applied to the keys 892 unique_ids = [] # list of each tile_id used in order 893 vals = [] # the data assigned to the key tiles 894 rots = [] # rotation of each key tile 895 subsets = self.map.groupby("tile_id") 896 for (id, var), geom, rot in zip(self.variables.items(), 897 tiles.geometry, 898 tiles.rotation): 899 subset = subsets.get_group(id) 900 d = subset[var] 901 radial = False 902 # if the data are categorical then it's complicated... 903 if d.dtype == pd.CategoricalDtype: 904 radial = True and self.radial_key 905 # desired order of categorical variable is the 906 # color maps dictionary keys 907 cmap = self.colourmaps[var] 908 num_cats = len(cmap) 909 val_order = dict(zip(cmap.keys(), range(num_cats))) 910 # compile counts of each category 911 freqs = [0] * num_cats 912 for v in list(d): 913 freqs[val_order[v]] += 1 914 # make list of the categories containing appropriate 915 # counts of each in the order needed using a reverse lookup 916 data_vals = list(val_order.keys()) 917 data_vals = [data_vals[i] for i, f in enumerate(freqs) if f > 0] 918 else: # any other data is easy! 919 data_vals = sorted(d) 920 freqs = [1] * len(data_vals) 921 key = self.tiling.tile_unit._get_legend_key_shapes( 922 geom, freqs, rot, radial) 923 key_tiles.extend(key) 924 vals.extend(data_vals) 925 n = len(data_vals) 926 ids.extend([id] * n) 927 unique_ids.append(id) 928 rots.extend([rot] * n) 929 # finally make up a data table with all the data in all the 930 # columns (each set of data only gets used in the subset it 931 # applies to). This allows us to reuse the tiling_utils. 932 # plot_subsetted_gdf() function 933 key_data = {} 934 for id in unique_ids: 935 key_data[self.variables[id]] = vals 936 937 key_gdf = gpd.GeoDataFrame( 938 data = key_data | {"tile_id": ids, "rotation": rots}, 939 crs = self.map.crs, 940 geometry = gpd.GeoSeries(key_tiles)) 941 key_gdf.geometry = key_gdf.rotate(self.tiling.rotation, origin = (0, 0)) 942 return key_gdf 943 944 945 def explore(self) -> None: 946 """TODO: add wrapper to make tiled web map via geopandas.explore. 947 """ 948 return None
Class representing a tiled map. Should not be accessed directly, but
will be created by calling Tiling.get_tiled_map()
. After creation the
variables and colourmaps attributes can be set, and then
TiledMap.render()
called to make a map. Settable attributes are explained
in documentation of the TiledMap.render()
method.
Examples:
Recommended usage is as follows. First, make a TiledMap
from a Tiling
object.
tm = tiling.get_tiled_map(...)
Some options in the Tiling
constructor affect the map appearance. See
Tiling
for details.
Once a TiledMap
object exists, set options on it, either when calling
TiledMap.render()
or explicitly, i.e.
tm.render(opt1 = val1, opt2 = val2, ...)
or
tm.opt1 = val1
tm.opt2 = val2
tm.render()
Option settings are persistent, i.e. unless a new TiledMap
object is
created the option settings have to be explicitly reset to default
values on subsequent calls to TiledMap.render()
.
The most important options are the variables
and colourmaps
settings.
variables
is a dictionary mapping weavingspace.tileable.Tileable
tile_ids (usually "a", "b", etc.) to variable names in the data. For
example,
tm.variables = dict(zip(["a", "b"], ["population", "income"]))
colourmaps
is a dictionary mapping dataset variable names to the
matplotlib colourmap to be used for each. For example,
tm.colourmaps = dict(zip(tm.variables.values(), ["Reds", "Blues"]))
See this notebook for simple usage. TODO: This more complicated example shows how categorical maps can be created.
if True use radial key even for ordinal/ratio data (normally these will be shown by concentric tile geometries)
604 def render(self, **kwargs) -> Figure: 605 """Renders the current state to a map. 606 607 Note that TiledMap objects will usually be created by calling 608 `Tiling.get_tiled_map()`. 609 610 Args: 611 variables (dict[str,str]): Mapping from tile_id values to 612 variable names. Defaults to None. 613 colourmaps (dict[str,Union[str,dict]]): Mapping from variable 614 names to colour map, either a colour palette as used by 615 geopandas/matplotlib, a fixed colour, or a dictionary mapping 616 categorical data values to colours. Defaults to None. 617 legend (bool): If True a legend will be drawn. Defaults to True. 618 legend_zoom (float): Zoom factor to apply to the legend. Values <1 619 will show more of the tile context. Defaults to 1.0. 620 legend_dx (float): x shift to apply to the legend position. 621 Defaults to 0.0. 622 legend_dy (float): x and y shift to apply to the legend position. 623 Defaults to 0.0. 624 use_ellipse (bool): If True applies an elliptical clip to the 625 legend. Defaults to False. 626 ellipse_magnification (float): Magnification to apply to ellipse 627 clipped legend. Defaults to 1.0. 628 radial_key (bool): If True legend key for TileUnit maps will be 629 based on radially dissecting the tiles. Defaults to False. 630 draft_mode (bool): If True a map of the tiled map coloured by 631 tile_ids (and with no legend) is returned. Defaults to False. 632 scheme (str): passed to geopandas.plot for numeric data. Defaults to 633 "equalinterval". 634 k (int): passed to geopandas.plot for numeric data. Defaults to 100. 635 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 636 plot. Defaults to (20,15). 637 dpi (float): passed to pyplot.plot. Defaults to 72. 638 **kwargs: other settings to pass to pyplot/geopandas.plot. 639 640 Returns: 641 matplotlib.figure.Figure: figure on which map is plotted. 642 """ 643 pyplot.rcParams['pdf.fonttype'] = 42 644 pyplot.rcParams['pdf.use14corefonts'] = True 645 matplotlib.rcParams['pdf.fonttype'] = 42 646 647 to_remove = set() # keep track of kwargs we use to setup TiledMap 648 for k, v in kwargs.items(): 649 if k in self.__dict__: 650 self.__dict__[k] = v 651 to_remove.add(k) 652 # remove them so we don't pass them on to pyplot and get errors 653 for k in to_remove: 654 del kwargs[k] 655 656 if self.draft_mode: 657 fig = pyplot.figure(figsize = self.figsize) 658 ax = fig.add_subplot(111) 659 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", 660 **kwargs) 661 return fig 662 663 if self.legend: 664 # this sizing stuff is rough and ready for now, possibly forever... 665 reg_w, reg_h, *_ = \ 666 tiling_utils.get_width_height_left_bottom(self.map.geometry) 667 tile_w, tile_h, *_ = \ 668 tiling_utils.get_width_height_left_bottom( 669 self.tiling.tile_unit._get_legend_tiles().rotate( 670 self.tiling.rotation, origin = (0, 0))) 671 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 672 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 673 "width_ratios": [reg_w, sf_w * tile_w]} 674 675 fig, axes = pyplot.subplot_mosaic( 676 [["map", "legend"], ["map", "."]], 677 gridspec_kw = gskw, figsize = self.figsize, 678 layout = "constrained", **kwargs) 679 else: 680 fig, axes = pyplot.subplots( 681 1, 1, figsize = self.figsize, 682 layout = "constrained", **kwargs) 683 684 if self.variables is None: 685 # get any floating point columns available 686 default_columns = \ 687 self.map.select_dtypes( 688 include = ("float64", "int64")).columns 689 self.variables = dict(zip(self.map.tile_id.unique(), 690 list(default_columns))) 691 print(f"""No variables specified, picked the first 692 {len(self.variables)} numeric ones available.""") 693 elif isinstance(self.variables, (list, tuple)): 694 self.variables = dict(zip( 695 self.tiling.tile_unit.tiles.tile_id.unique(), 696 self.variables)) 697 print(f"""Only a list of variables specified, assigning to 698 available tile_ids.""") 699 700 if self.colourmaps is None: 701 self.colourmaps = {} 702 for var in self.variables.values(): 703 if self.map[var].dtype == pd.CategoricalDtype: 704 self.colourmaps[var] = "tab20" 705 print(f"""For categorical data, you should specify colour 706 mapping explicitly.""") 707 else: 708 self.colourmaps[var] = "Reds" 709 710 self._plot_map(axes, **kwargs) 711 return fig
Renders the current state to a map.
Note that TiledMap objects will usually be created by calling
Tiling.get_tiled_map()
.
Args: variables (dict[str,str]): Mapping from tile_id values to variable names. Defaults to None. colourmaps (dict[str,Union[str,dict]]): Mapping from variable names to colour map, either a colour palette as used by geopandas/matplotlib, a fixed colour, or a dictionary mapping categorical data values to colours. Defaults to None. legend (bool): If True a legend will be drawn. Defaults to True. legend_zoom (float): Zoom factor to apply to the legend. Values <1 will show more of the tile context. Defaults to 1.0. legend_dx (float): x shift to apply to the legend position. Defaults to 0.0. legend_dy (float): x and y shift to apply to the legend position. Defaults to 0.0. use_ellipse (bool): If True applies an elliptical clip to the legend. Defaults to False. ellipse_magnification (float): Magnification to apply to ellipse clipped legend. Defaults to 1.0. radial_key (bool): If True legend key for TileUnit maps will be based on radially dissecting the tiles. Defaults to False. draft_mode (bool): If True a map of the tiled map coloured by tile_ids (and with no legend) is returned. Defaults to False. scheme (str): passed to geopandas.plot for numeric data. Defaults to "equalinterval". k (int): passed to geopandas.plot for numeric data. Defaults to 100. figsize (tuple[float,floar]): plot dimensions passed to geopandas. plot. Defaults to (20,15). dpi (float): passed to pyplot.plot. Defaults to 72. **kwargs: other settings to pass to pyplot/geopandas.plot.
Returns: matplotlib.figure.Figure: figure on which map is plotted.
790 def to_file(self, fname:str = None) -> None: 791 """Outputs the tiled map to a layered GPKG file. 792 793 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 794 795 Args: 796 fname (str, optional): Filename to write. Defaults to None. 797 """ 798 tiling_utils.write_map_to_layers(self.map, fname) 799 return None
Outputs the tiled map to a layered GPKG file.
Currently delegates to weavingspace.tiling_utils.write_map_to_layers()
.
Args: fname (str, optional): Filename to write. Defaults to None.
802 def plot_legend(self, ax: pyplot.Axes = None, **kwargs) -> None: 803 """Plots a legend for this tiled map. 804 805 Args: 806 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 807 """ 808 # turn off axes (which seems also to make it impossible 809 # to set a background colour) 810 ax.set_axis_off() 811 812 legend_tiles = self.tiling.tile_unit._get_legend_tiles() 813 # this is a bit hacky, but we will apply the rotation to text 814 # annotation so for TileUnits which don't need it, reverse that now 815 if isinstance(self.tiling.tile_unit, TileUnit): 816 legend_tiles.rotation = -self.tiling.rotation 817 818 legend_key = self._get_legend_key_gdf(legend_tiles) 819 820 legend_tiles.geometry = legend_tiles.geometry.rotate( 821 self.tiling.rotation, origin = (0, 0)) 822 823 if self.use_ellipse: 824 ellipse = tiling_utils.get_bounding_ellipse( 825 legend_tiles.geometry, mag = self.ellipse_magnification) 826 bb = ellipse.total_bounds 827 c = ellipse.unary_union.centroid 828 else: 829 bb = legend_tiles.geometry.total_bounds 830 c = legend_tiles.geometry.unary_union.centroid 831 832 # apply legend zoom - NOTE that this must be applied even 833 # if self.legend_zoom is not == 1... 834 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 835 c.x + (bb[2] - c.x) / self.legend_zoom) 836 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 837 c.y + (bb[3] - c.y) / self.legend_zoom) 838 839 # plot the legend key tiles (which include the data) 840 self._plot_subsetted_gdf(ax, legend_key, lw = 0, **kwargs) 841 842 for id, tile, rotn in zip(self.variables.keys(), 843 legend_tiles.geometry, 844 legend_tiles.rotation): 845 c = tile.centroid 846 ax.annotate(self.variables[id], xy = (c.x, c.y), 847 ha = "center", va = "center", rotation_mode = "anchor", 848 # adjust rotation to favour text reading left to right 849 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 850 bbox = {"lw": 0, "fc": "#ffffff40"}) 851 852 # now plot background; we include the central tiles, since in 853 # the weave case these may not match the legend tiles 854 context_tiles = self.tiling.tile_unit.get_local_patch(r = 2, 855 include_0 = True).geometry.rotate(self.tiling.rotation, origin = (0, 0)) 856 # for reasons escaping all reason... invalid polygons sometimes show up 857 # here I think because of the rotation /shrug... in any case, this 858 # sledgehammer should fix it 859 # context_tiles = gpd.GeoSeries([g.simplify(1e-6) 860 # for g in context_tiles.geometry], 861 # crs = self.tiling.tile_unit.crs) 862 863 if self.use_ellipse: 864 context_tiles.clip(ellipse, keep_geom_type = False).plot( 865 ax = ax, fc = "#9F9F9F3F", lw = 0.0) 866 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 867 ellipse, keep_geom_type = True).plot(ax = ax, ec = "#5F5F5F", lw = 1) 868 else: 869 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", ec = "#5F5F5F", lw = 0.0) 870 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 871 ax = ax, ec = "#5F5F5F", lw = 1)
Plots a legend for this tiled map.
Args: ax (pyplot.Axes, optional): axes to draw legend. Defaults to None.