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