weavingspace.tile_map
Classes for tiling maps.
weavingspace.tile_map.Tiling
and
weavingspace.tile_map.TiledMap
are exposed in the public API and
respectively enable creation of a tiling and plotting of the tiling as a
multivariate map.
1"""Classes for tiling maps. 2 3`weavingspace.tile_map.Tiling` and 4`weavingspace.tile_map.TiledMap` are exposed in the public API and 5respectively enable creation of a tiling and plotting of the tiling as a 6multivariate map. 7""" 8 9from __future__ import annotations 10 11import copy 12import itertools 13import typing 14from collections.abc import Iterable 15from dataclasses import dataclass 16from time import perf_counter 17 18import geopandas as gpd 19import matplotlib.colors 20import matplotlib.pyplot as plt 21import numpy as np 22import pandas as pd 23import shapely.affinity as affine 24import shapely.geometry as geom 25import shapely.ops 26 27from weavingspace import Tileable, TileUnit, tiling_utils 28 29if typing.TYPE_CHECKING: 30 from matplotlib.figure import Figure 31 32CMAPS_SEQUENTIAL = list(itertools.chain(*[[x, x + "_r"] for x in 33 ["Greys", "Purples", "Blues", "Greens", "Oranges", "Reds", 34 "viridis", "plasma", "inferno", "magma", "cividis", 35 "YlOrBr", "YlOrRd", "OrRd", "PuRd", "RdPu", "BuPu", 36 "GnBu", "PuBu", "YlGnBu", "PuBuGn", "BuGn", "YlGn", 37 "binary", "gist_yarg", "gist_gray", "gray", "bone", 38 "pink", "spring", "summer", "autumn", "winter", "cool", 39 "Wistia", "hot", "afmhot", "gist_heat", "copper"]])) 40 41CMAPS_DIVERGING = list(itertools.chain(*[[x, x + "_r"] for x in 42 ["PiYG", "PRGn", "BrBG", "PuOr", "RdGy", "RdBu", "RdYlBu", 43 "RdYlGn", "Spectral", "coolwarm", "bwr", "seismic"]])) 44# these ones not yet in MPL 3.8.4: "berlin", "managua", "vanimo" 45 46CMAPS_CATEGORICAL = list(itertools.chain(*[[x, x + "_r"] for x in 47 ["Pastel1", "Pastel2", "Paired", "Accent", "Dark2", 48 "Set1", "Set2", "Set3", "tab10", "tab20", "tab20b", 49 "tab20c"]])) 50 51@dataclass(slots=True, init=False) 52class _TileGrid: 53 """A class to represent the translation centres of a tiling. 54 55 We store the grid as a GeoSeries of Point objects to make it simple to plot 56 in map views if required. 57 58 Implementation relies on transforming the translation vectors into a square 59 space where the tile spacing is unit squares, then transforming this back 60 into the original map space. Some member variables of the class are in 61 the transformed grid generation space, some in the map space. We store the 62 translation vectors as shapely Points, and the extent of the grid in grid 63 space as a shapely Polygon so we can more easily visualise if needed. 64 """ 65 66 tile_unit:Tileable 67 """the base tile in map space.""" 68 oriented_rect_to_tile:geom.Polygon 69 """oriented rectangular region in map space that is to be tiled.""" 70 to_grid_space:tuple[float,...] 71 """the forward transformation from map space to tiling grid generation space, 72 stored as a shapely.affinity transform tuple of 6 floats.""" 73 to_map_space:tuple[float,...] 74 """the inverse transform from tiling grid space to map space.""" 75 extent_in_grid_space:geom.Polygon 76 """geometry of the circular extent of the tiling transformed into grid 77 generation space.""" 78 centre:geom.Point 79 """centre point of the grid in map space - this is required for later 80 rotations of a generated tiling in map space""" 81 points:gpd.GeoSeries 82 """geom.Points recording translation vectors of the tiling in map space.""" 83 84 def __init__( 85 self, 86 tile_unit:Tileable, 87 to_tile:gpd.GeoSeries, 88 at_centroids:bool = False) -> None: 89 self.tile_unit = tile_unit 90 self.oriented_rect_to_tile = self._get_rect_to_tile(to_tile) 91 self.to_map_space, self.to_grid_space = self._get_transforms() 92 self._set_centre_in_map_space() 93 self._set_extent_in_grid_space() 94 if at_centroids: 95 self.points = to_tile.representative_point() 96 else: 97 self.points = self._get_grid() 98 self.points.crs = self.tile_unit.crs 99 100 101 def _get_rect_to_tile( 102 self, 103 region_to_tile:gpd.GeoSeries) -> geom.Polygon: 104 """Generate an oriented rectangle that encompasses the region to be tiled. 105 106 This will be rotated to generate a circular are that will actually be tiled 107 so that if new orientations of the tiled pattern are requested they can be 108 generated quickly without much recalculation. 109 110 Args: 111 region_to_tile (gpd.GeoSeries): the region to be tiled. 112 113 Returns: 114 geom.Polygon: an oriented rectangle encompassing the region to be tiled 115 with some buffering to avoid cutting off tiling elements. 116 117 """ 118 # buffer the region by an amount dictated by the size of the tile unit 119 bb = self.tile_unit.tiles.total_bounds 120 diagonal = np.hypot(bb[2] - bb[0], bb[3] - bb[1]) 121 return (region_to_tile.union_all().buffer(diagonal) 122 .minimum_rotated_rectangle) 123 124 125 def _get_transforms(self) -> tuple[tuple[float,...],tuple[float,...]]: 126 """Return forward and inverse transforms from map space to grid space. 127 128 In grid generation space the translation vectors are (1, 0) and (0, 1) 129 so we simply form a matrix from two translation vectors to get the 130 transform from grid space to map space, and invert it to get transform from 131 map space to grid space. 132 133 See pages 18-22 in Kaplan CS, 2009. Introductory Tiling Theory for Computer 134 Graphics (Morgan & Claypool) for the logic of this approach. 135 136 The results are returned as shapely affine transform tuples. 137 138 Returns: 139 tuple[tuple[float,...],tuple[float,...]]: shapely affine transform tuples 140 (a, b, d, e, dx, dy). 141 142 """ 143 v = self.tile_unit.get_vectors() 144 # unpack the first two vectors and funnel them into a 2x2 matrix 145 grid_to_map = np.array([[v[0][0], v[1][0]], [v[0][1], v[1][1]]]) 146 map_to_grid = np.linalg.inv(grid_to_map) 147 return (self._np_to_shapely_transform(grid_to_map), 148 self._np_to_shapely_transform(map_to_grid)) 149 150 151 def _set_centre_in_map_space(self) -> None: 152 """Set the tiling centre in map space.""" 153 self.centre = self.oriented_rect_to_tile.centroid 154 155 156 def _set_extent_in_grid_space(self) -> None: 157 """Set extent of the grid in grid generation space.""" 158 corner = geom.Point(self.oriented_rect_to_tile.exterior.coords[0]) 159 radius = self.centre.distance(corner) 160 self.extent_in_grid_space = \ 161 affine.affine_transform(self.centre.buffer(radius), self.to_grid_space) 162 163 164 def _get_grid(self) -> gpd.GeoSeries: 165 """Generate the grid transformed into map space. 166 167 Obtain dimensions of the transformed region, then set down a uniform grid. 168 Grid generation is greatly accelerated by using the numpy meshgrid method 169 which we can do because we're working in a grid-generation space where 170 tile units have been transformed to the unit square. 171 172 Returns: 173 gpd.GeoSeries: the grid as a collection of geom.Points. 174 175 """ 176 bb = self.extent_in_grid_space.bounds 177 _w, _h, _l, _b = bb[2] - bb[0], bb[3] - bb[1], bb[0], bb[1] 178 w = int(np.ceil(_w)) 179 h = int(np.ceil(_h)) 180 l = _l - (w - _w) / 2 181 b = _b - (h - _h) / 2 182 xs, ys = np.array(np.meshgrid(np.arange(w) + l, 183 np.arange(h) + b)).reshape((2, w * h)) 184 pts = [geom.Point(x, y) for x, y in zip(xs, ys, strict = True)] 185 return (gpd.GeoSeries( 186 [p for p in pts if p.within(self.extent_in_grid_space)]) 187 .affine_transform(self.to_map_space)) 188 189 190 def _np_to_shapely_transform( 191 self, 192 array:np.ndarray) -> tuple[float,...]: 193 """Convert a numpy affine transform matrix to shapely format. 194 195 [[a b c] 196 [d e f] --> (a b d e c f) 197 [g h i]] 198 199 There is no translation in transforms in this use-case so c == f == 0 200 201 Args: 202 array (np.ndarray): numpy affine transform array to convert. 203 204 Returns: 205 tuple[float,...]: shapely affine transform tuple 206 207 """ 208 return (*list(array[:2, :2].flatten()), 0, 0) 209 210 211@dataclass(slots=True, init=False) 212class Tiling: 213 """Class that applies a `Tileable` object to a region to be mapped. 214 215 The result of the tiling procedure is stored in the `tiles` variable and 216 covers a region sufficient that the tiling can be rotated to any desired 217 angle. Rotation can be requested when the render method is called. 218 """ 219 220 tileable:Tileable 221 """Tileable on which the tiling is based.""" 222 region:gpd.GeoDataFrame 223 """the region to be tiled.""" 224 region_union:geom.Polygon 225 """a single polygon of all the areas in the region to be tiled""" 226 grid:_TileGrid 227 """the grid which will be used to apply the tiling.""" 228 tiles:gpd.GeoDataFrame 229 """the tiles after tiling has been carried out.""" 230 prototiles:gpd.GeoDataFrame 231 """the prototiles after tiling has been carried out.""" 232 rotation:float 233 """additional rotation applied to the tiling beyond any that might have 234 been 'baked in' to the Tileable.""" 235 236 def __init__( 237 self, 238 tileable:Tileable, 239 region:gpd.GeoDataFrame, 240 as_icons:bool = False, 241 ) -> None: 242 """Construct a tiling by polygons extending beyond supplied region. 243 244 The tiling is extended sufficiently to allow for its application at any 245 rotation. 246 247 Args: 248 tileable (Tileable): the TileUnit or WeaveUnit to use. 249 region (gpd.GeoDataFrame): the region to be tiled. 250 as_icons (bool, optional): if True prototiles will only be placed at the 251 region's zone centroids, one per zone. Defaults to False. 252 253 """ 254 self.tileable = tileable 255 self.rotation = 0 256 self.region = region 257 self.region.sindex # this probably speeds up overlay 258 self.region_union = self.region.geometry.union_all() 259 self.grid = _TileGrid( 260 self.tileable, 261 self.region.geometry if as_icons else gpd.GeoSeries([self.region_union]), 262 as_icons) 263 self.tiles, self.prototiles = self.make_tiling() 264 self.tiles.sindex # again this probably speeds up overlay 265 266 267 def get_tiled_map( 268 self, 269 rotation:float = 0., 270 join_on_prototiles:bool = False, 271 retain_tileables:bool = False, 272 prioritise_tiles:bool = True, 273 ragged_edges:bool = True, 274 use_centroid_lookup_approximation:bool = False, 275 debug:bool = False, 276 ) -> TiledMap: 277 """Return a `TiledMap` filling a region at the requested rotation. 278 279 HERE BE DRAGONS! This function took a lot of trial and error to get right, 280 so modify with CAUTION! 281 282 The `proritise_tiles = True` option means that the tiling will not break 283 up the tiles in `TileUnit`s at the boundaries between areas in the mapped 284 region, but will instead ensure that tiles remain complete, picking up 285 their data from the region zone which they overlap the most. 286 287 The exact order in which operations are performed affects performance. For 288 example, the final clipping to self.region when ragged_edges = False is 289 _much_ slower if it is carried out before the dissolving of tiles into the 290 region zones. So... again... modify CAREFULLY! 291 292 Args: 293 rotation (float, optional): Optional rotation to apply. Defaults to 0. 294 join_on_prototiles (bool, optional): if True data from the region dataset 295 are joined to tiles based on the prototile to which they belong. If 296 False the join is based on the tiles in relation to the region areas. 297 For weave-based tilings False is probably to be preferred. Defaults to 298 False. 299 retain_tileables (bool, optional): if True complete tileable units will 300 be retained. If False tile unit elements that do not overlap the map 301 area will be discarded. 302 prioritise_tiles (bool, optional): if True tiles will not be broken at 303 boundaries in the region dataset. Defaults to True. 304 ragged_edges (bool, optional): if True tiles at the edge of the region 305 will not be cut by the region extent - ignored if prioritise_tiles is 306 False when edges will always be clipped to the region extent. Defaults 307 to True. 308 use_centroid_lookup_approximation (bool, optional): if True use tile 309 centroids for lookup of region data - ignored if prioritise_tiles is 310 False when it is irrelevant. Defaults to False. 311 debug (bool, optional): if True prints timing messages. Defaults to 312 False. 313 314 Returns: 315 TiledMap: a TiledMap of the region with attributes attached to tiles. 316 317 """ 318 if debug: 319 t1 = perf_counter() 320 321 id_var = self._setup_region_DZID() 322 if join_on_prototiles: 323 if rotation == 0: 324 tiled_map, join_layer = self.tiles, self.prototiles 325 else: 326 tiled_map, join_layer = self.rotated(rotation) 327 tiled_map["joinUID"] = self.tiles["prototile_id"] 328 else: 329 tiled_map = self.tiles if rotation == 0 else self.rotated(rotation)[0] 330 tiled_map["joinUID"] = self.tiles["tile_id"] 331 join_layer = tiled_map 332 join_layer["joinUID"] = list(range(join_layer.shape[0])) 333 334 # compile a list of the variable names we are NOT going to change 335 # i.e. everything except the geometry and the id_var 336 region_vars = [column for column in self.region.columns 337 if "geom" not in column and column != id_var] 338 339 if debug: 340 t2 = perf_counter() 341 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 342 343 if prioritise_tiles: 344 # maintain tile continuity across zone boundaries 345 # so we have to do more work than a simple overlay 346 if use_centroid_lookup_approximation: 347 t5 = perf_counter() 348 tile_pts = copy.deepcopy(join_layer) 349 tile_pts.geometry = tile_pts.centroid 350 lookup = tile_pts.sjoin( 351 self.region, how = "inner")[["joinUID", id_var]] 352 else: 353 # determine areas of overlapping tiles and drop the data we join the 354 # data back later, so dropping makes that easier overlaying in region. 355 # overlay(tiles) seems to be faster?? 356 # TODO: also... this part is performance-critical, think about fixes -- 357 # possibly including the above centroid-based approximation 358 overlaps = self.region.overlay(join_layer, make_valid = False) 359 if debug: 360 t3 = perf_counter() 361 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 362 overlaps["area"] = overlaps.geometry.area 363 if debug: 364 t4 = perf_counter() 365 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 366 overlaps.drop(columns = region_vars, inplace = True) 367 if debug: 368 t5 = perf_counter() 369 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 370 # make a lookup by largest area tile to region id 371 lookup = overlaps \ 372 .iloc[overlaps.groupby("joinUID")["area"] \ 373 .agg(pd.Series.idxmax)][["joinUID", id_var]] 374 # now join the lookup and from there the region data 375 if debug: 376 t6 = perf_counter() 377 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 378 tiled_map = tiled_map \ 379 .merge(lookup, on = "joinUID") \ 380 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 381 if debug: 382 t7 = perf_counter() 383 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 384 tiled_map.drop(columns = ["joinUID"], inplace = True) 385 386 else: 387 # here it's a simple overlay 388 tiled_map = self.region.overlay(tiled_map) 389 t7 = perf_counter() 390 if debug: 391 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 392 393 if not retain_tileables: 394 tiled_map = tiled_map.loc[ 395 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 396 397 tiled_map.drop(columns = [id_var], inplace = True) 398 self.region.drop(columns = [id_var], inplace = True) 399 400 # if we've retained tiles and want 'clean' edges, then clip 401 # note that this step is slow: geopandas unary_unions the clip layer 402 if prioritise_tiles and not ragged_edges: 403 tiled_map.sindex 404 tiled_map = tiled_map.clip(self.region_union) 405 if debug: 406 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 407 408 tm = TiledMap() 409 tm.tiling = self 410 tm.map = tiled_map 411 return tm 412 413 414 def _setup_region_DZID(self) -> str: 415 """Return guaranteed-unique new attribute name for self.region dataframe. 416 417 Avoids a name clash with any existing attribute in the dataframe. 418 419 Returns: 420 str: name of the added attribute. 421 422 """ 423 dzid = "DZID" 424 i = 0 425 while dzid in self.region.columns: 426 dzid = "DZID" + str(i) 427 i = i + 1 428 self.region[dzid] = list(range(self.region.shape[0])) 429 return dzid 430 431 432 def make_tiling(self) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 433 """Tile the region with a tile unit, returning a GeoDataFrame. 434 435 Returns: 436 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 437 tile unit. 438 439 """ 440 # we assume the geometry column is called geometry so make it so... 441 if self.region.geometry.name != "geometry": 442 self.region.rename_geometry("geometry", inplace = True) 443 444 # chain list of lists of GeoSeries geometries to list of geometries 445 tiles = itertools.chain(*[ 446 self.tileable.tiles.geometry.translate(p.x, p.y) 447 for p in self.grid.points]) 448 prototiles = itertools.chain(*[ 449 self.tileable.prototile.geometry.translate(p.x, p.y) 450 for p in self.grid.points]) 451 # replicate the tile ids 452 tile_ids = list(self.tileable.tiles.tile_id) * len(self.grid.points) 453 prototile_ids = list(range(len(self.grid.points))) 454 tile_prototile_ids = sorted(prototile_ids * self.tileable.tiles.shape[0]) 455 tiles_gs = gpd.GeoSeries(list(tiles)) 456 prototiles_gs = gpd.GeoSeries(list(prototiles)) 457 # assemble and return as GeoDataFrames 458 tiles_gdf = gpd.GeoDataFrame( 459 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 460 geometry = tiles_gs, crs = self.tileable.crs) 461 prototiles_gdf = gpd.GeoDataFrame( 462 data = {"prototile_id": prototile_ids}, 463 geometry = prototiles_gs, crs = self.tileable.crs) 464 return tiles_gdf, prototiles_gdf 465 466 467 def rotated(self, 468 rotation:float = 0.0, 469 ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 470 """Return the stored tiling rotated. 471 472 The stored tiling never changes and 473 if it was originally made with a Tileable that was rotated it will retain 474 that rotation. The requested rotation is _additional_ to that baseline 475 rotation. 476 477 Args: 478 rotation (float, optional): Rotation angle in degrees. 479 Defaults to None. 480 481 Returns: 482 gpd.GeoDataFrame: Rotated tiling. 483 484 """ 485 if self.tiles is None: 486 self.tiles = self.make_tiling()[0] 487 if rotation == 0: 488 return self.tiles, self.prototiles 489 tiles = gpd.GeoDataFrame( 490 data = {"tile_id": self.tiles.tile_id, 491 "prototile_id": self.tiles.tile_id}, 492 crs = self.tiles.crs, 493 geometry = self.tiles.geometry.rotate( 494 rotation, origin = self.grid.centre)) 495 prototiles = gpd.GeoDataFrame( 496 data = {"prototile_id": self.prototiles.prototile_id}, 497 crs = self.prototiles.crs, 498 geometry = self.prototiles.geometry.rotate( 499 rotation, origin = self.grid.centre)) 500 self.rotation = rotation 501 return tiles, prototiles 502 503 504@dataclass(slots=True) 505class TiledMap: 506 """Class representing a tiled map. 507 508 Should not be accessed directly, but will 509 be created by calling `Tiling.get_tiled_map()`. After creation the variables 510 and colourmaps attributes can be set, and then `TiledMap.render()` called to 511 make a map. Settable attributes are explained in documentation of the 512 `TiledMap.render()` method. 513 514 Examples: 515 Recommended usage is as follows. First, make a `TiledMap` from a `Tiling` 516 object: 517 518 `tm = tiling.get_tiled_map(...)` 519 520 Some options in the `Tiling` constructor affect the map appearance. See 521 `Tiling` for details. 522 523 Once a `TiledMap` object exists, set options on it, either when calling 524 `TiledMap.render()` or explicitly, i.e. 525 526 tm.render(opt1 = val1, opt2 = val2, ...) 527 528 or 529 530 tm.opt1 = val1 531 tm.opt2 = val2 532 tm.render() 533 534 Option settings are persistent, i.e. unless a new `TiledMap` object is 535 created the option settings have to be explicitly reset to new values on 536 subsequent calls to `TiledMap.render()`. 537 538 The most important options are the `vars_map` and `colors_to_use` settings. 539 540 `vars_to_map` is a lost of the dataset variable names to match with 541 `weavingspace.tileable.Tileable` elements with corresponding (ordered) 542 tile_ids (usually "a", "b", etc.). If you need to control the match, then 543 also supply `ids_to_map` in matching order. E.g. 544 545 tm.ids_to_map = ['d', 'c', 'b', 'a'] 546 tm.vars_to_map = ['x1', 'x2', 'x3', 'x4'] 547 548 Note that this means that if you really want more than one element in the 549 tiling to represent the same variable more than once, you can do that. 550 551 `colors_to_use` is a parallel list of named matplotlib colormaps, 552 553 tm.colors_to_use = ["Reds", "Blues", "Greys", "Purples"] 554 555 Similarly, you can specify the classification `schemes_to_use` (such as 556 'quantiles') and the number of classes `n_classes` in each. 557 558 If data are categorical, this is flagged in the `categoricals` list of 559 booleans, in which case an appropriate colour map should be used. There is 560 currently no provision for control of which colour in a categorical 561 colour map is applied to which variable level. 562 563 TODO: better control of categorical mapping schemes. 564 565 """ 566 567 # these will be set at instantion by Tiling.get_tiled_map() 568 tiling:Tiling = None 569 """the Tiling with the required tiles""" 570 map:gpd.GeoDataFrame = None 571 """the GeoDataFrame on which this map is based""" 572 ids_to_map:list[str] = None 573 """tile_ids that are to be used to represent data""" 574 vars_to_map:list[str] = None 575 """dataset variables that are to be symbolised""" 576 colors_to_use:list[str|list[str]] = None 577 """list of matplotlib colormap names.""" 578 categoricals:list[bool] = None 579 """list specifying if each variable is -- or is to be treated as -- 580 categorical""" 581 schemes_to_use:list[str|None] = None 582 """mapclassify schemes to use for each variable.""" 583 n_classes:list[int|None] = None 584 """number of classes to apply; if set to 0 will be unclassed.""" 585 _colourspecs:dict[str,dict] = None 586 """dictionary of dictionaries keyed by the items in `ids_to_use` with each 587 dictionary forming additional kwargs to be supplied to geopandas.plot().""" 588 589 # the below parameters can be set either before calling self.render() or 590 # passed in as parameters to self.render(). These are solely 591 # `TiledMap.render()` options not geopandas plot options. 592 legend:bool = True 593 """whether or not to show a legend""" 594 legend_zoom:float = 1.0 595 """<1 zooms out from legend to show more context""" 596 legend_dx:float = 0. 597 """x shift of legend relative to the map""" 598 legend_dy:float = 0. 599 """y shift of legend relative to the map""" 600 use_ellipse:bool = False 601 """if True clips legend with an ellipse""" 602 ellipse_magnification:float = 1.0 603 """magnification to apply to clip ellipse""" 604 radial_key:bool = False 605 """if True use radial key even for ordinal/ratio data (normally these will be 606 shown by concentric tile geometries)""" 607 draft_mode:bool = False 608 """if True plot the map coloured by tile_id""" 609 610 # the parameters below are geopandas.plot options which we intercept to 611 # ensure they are applied appropriately when we plot a GDF 612 figsize:tuple[float,float] = (20, 15) 613 """maptlotlib figsize""" 614 dpi:float = 72 615 """dpi for bitmap formats""" 616 617 def render( 618 self, 619 **kwargs, 620 ) -> Figure: 621 """Render the current state to a map. 622 623 Note that TiledMap objects will usually be created by calling 624 `Tiling.get_tiled_map()`. 625 626 Args: 627 ids_to_map (list[str]): tile_ids to be used in the map. Defaults to None. 628 vars_to_map (list[str]): dataset columns to be mapped. Defaults to None. 629 colors_to_use (list[str]): list of matplotlib colormaps to be used. 630 Defaults to None. 631 categoricals (list[bool]): list of flags indicating if associated variable 632 should be treated as categorical. Defaults to None. 633 schemes_to_use (list[str]): list of strings indicating the mapclassify 634 scheme to use e.g. 'equalinterval' or 'quantiles'. Defaults to None. 635 n_classes (list[int]): list of ints indicating number of classes to use in 636 map classification. Defaults to None. 637 legend (bool): If True a legend will be drawn. Defaults to True. 638 legend_zoom (float): Zoom factor to apply to the legend. Values <1 639 will show more of the tile context. Defaults to 1.0. 640 legend_dx (float): x shift to apply to the legend position in plot area 641 relative units, i.e. 1.0 is full width of plot. Defaults to 0.0. 642 legend_dy (float): x and y shift to apply to the legend position in plot 643 area relative units, i.e. 1.0 is full height of plot. Defaults to 0.0. 644 use_ellipse (bool): If True applies an elliptical clip to the legend. 645 Defaults to False. 646 ellipse_magnification (float): Magnification to apply to ellipse clipped 647 legend. Defaults to 1.0. 648 radial_key (bool): If True legend key for TileUnit maps will be based on 649 radially dissecting the tiles, i.e. pie slices. Defaults to False. 650 draft_mode (bool): If True a map of the tiled map coloured by tile_ids 651 (and with no legend) is returned. Defaults to False. 652 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 653 plot. Defaults to (20, 15). 654 dpi (float): passed to pyplot.plot. Defaults to 72. 655 **kwargs: other settings to pass to pyplot/geopandas.plot. 656 657 Returns: 658 matplotlib.figure.Figure: figure on which map is plotted. 659 660 """ 661 plt.rcParams["pdf.fonttype"] = 42 662 plt.rcParams["pdf.use14corefonts"] = True 663 matplotlib.rcParams["pdf.fonttype"] = 42 664 665 to_remove = set() # keep track of kwargs we use to setup TiledMap 666 # kwargs with no corresponding class attribute will be discarded 667 # because we are using slots, we have to use setattr() here 668 for k, v in kwargs.items(): 669 if k in self.__slots__: 670 setattr(self, k, v) 671 to_remove.add(k) 672 # remove any them so we don't pass them on to pyplot and get errors 673 for k in to_remove: 674 del kwargs[k] 675 676 if self.draft_mode: 677 fig = plt.figure(figsize = self.figsize) 678 ax = fig.add_subplot(111) 679 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", **kwargs) 680 ax.set_axis_off() 681 return fig 682 683 if self.legend: 684 # this sizing stuff is rough and ready for now, possibly forever... 685 reg_w, reg_h, *_ = \ 686 tiling_utils.get_width_height_left_bottom(self.map.geometry) 687 tile_w, tile_h, *_ = \ 688 tiling_utils.get_width_height_left_bottom( 689 self.tiling.tileable._get_legend_tiles().rotate( 690 self.tiling.rotation, origin = (0, 0))) 691 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 692 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 693 "width_ratios": [reg_w, sf_w * tile_w]} 694 695 fig, axes = plt.subplot_mosaic([["map", "legend"], ["map", "."]], 696 gridspec_kw = gskw, 697 figsize = self.figsize, 698 layout = "constrained", **kwargs) 699 else: 700 fig, axes = plt.subplots(1, 1, figsize = self.figsize, 701 layout = "constrained", **kwargs) 702 703 self._plot_map(axes, **kwargs) 704 return fig 705 706 707 def _plot_map( 708 self, 709 ax:plt.Axes, 710 **kwargs, 711 ) -> None: 712 """Plot map to the supplied axes. 713 714 Args: 715 axes (plt.Axes): axes on which maps will be drawn. 716 kwargs (dict): additional parameters to be passed to plot. 717 718 """ 719 self._set_colourspecs() 720 bb = self.map.geometry.total_bounds 721 if self.legend: 722 if (self.legend_dx != 0 or self.legend_dx != 0): 723 box = ax["legend"].get_position() 724 box.x0 += self.legend_dx 725 box.x1 += self.legend_dx 726 box.y0 += self.legend_dy 727 box.y1 += self.legend_dy 728 ax["legend"].set_position(box) 729 ax["map"].set_axis_off() 730 ax["map"].set_xlim(bb[0], bb[2]) 731 ax["map"].set_ylim(bb[1], bb[3]) 732 self._plot_subsetted_gdf(ax["map"], self.map, **kwargs) 733 self.plot_legend(ax = ax["legend"], **kwargs) 734 else: 735 ax.set_axis_off() 736 ax.set_xlim(bb[0], bb[2]) 737 ax.set_ylim(bb[1], bb[3]) 738 self._plot_subsetted_gdf(ax, self.map, **kwargs) 739 740 741 def _plot_subsetted_gdf( 742 self, 743 ax:plt.Axes, 744 gdf:gpd.GeoDataFrame, 745 grouping_var:str = "tile_id", 746 **kwargs, 747 ) -> None: 748 """Repeatedly plot a gpd.GeoDataFrame based on a subsetting attribute. 749 750 NOTE: used to plot both the main map _and_ the legend, which is why 751 a separate GeoDataframe is supplied and we don't just use self.map. 752 753 Args: 754 ax (pyplot.Axes): axes to plot to. 755 gdf (gpd.GeoDataFrame): the GeoDataFrame to plot. 756 757 Raises: 758 Exception: if self.colourmaps cannot be parsed exception is raised. 759 760 """ 761 groups = gdf.groupby(grouping_var) 762 for ID, cspec in self._colourspecs.items(): 763 subset = groups.get_group(ID) 764 n_values = len(subset[cspec["column"]].unique()) 765 if not cspec["categorical"] and n_values == 1: 766 print(f""" 767 Only one level in variable {cspec['column']}, replacing requested 768 colour map with single colour fill. 769 """) 770 cspec["color"] = matplotlib.colormaps.get(cspec["cmap"])(0.5) 771 del cspec["column"] 772 del cspec["cmap"] 773 del cspec["scheme"] 774 elif not cspec["categorical"] and n_values < cspec["k"]: 775 cspec["k"] = n_values 776 elif cspec["categorical"]: 777 del cspec["scheme"] 778 subset.plot(ax = ax, **cspec, **kwargs) 779 780 781 def to_file(self, fname:str) -> None: 782 """Output the tiled map to a layered GPKG file. 783 784 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 785 786 Args: 787 fname (str): Filename to write. Defaults to None. 788 789 """ 790 tiling_utils.write_map_to_layers(self.map, fname) 791 792 793 def plot_legend(self, ax, **kwargs) -> None: 794 """Plot a legend for this tiled map. 795 796 Args: 797 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 798 799 """ 800 ax.set_axis_off() 801 legend_tiles = self.tiling.tileable._get_legend_tiles() 802 # this is a bit hacky, but we will apply the rotation to text 803 # annotation so for TileUnits which don't need it, reverse that now 804 if isinstance(self.tiling.tileable, TileUnit): 805 # note that this confuses type hinting because pandas silently assigns 806 # a scalar value to a Series 807 legend_tiles.rotation = -self.tiling.rotation 808 809 legend_key = self._get_legend_key_gdf(legend_tiles) 810 legend_tiles.geometry = legend_tiles.geometry.rotate( 811 self.tiling.rotation, origin = (0, 0)) 812 813 if self.use_ellipse: 814 ellipse = tiling_utils.get_bounding_ellipse( 815 legend_tiles.geometry, mag = self.ellipse_magnification) 816 bb = ellipse.total_bounds 817 c = ellipse.union_all().centroid 818 else: 819 bb = legend_tiles.geometry.total_bounds 820 c = legend_tiles.geometry.union_all().centroid 821 822 # apply legend zoom - NOTE that this must be applied even 823 # if self.legend_zoom is == 1... 824 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 825 c.x + (bb[2] - c.x) / self.legend_zoom) 826 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 827 c.y + (bb[3] - c.y) / self.legend_zoom) 828 829 for cs, tile, rotn in zip(self._colourspecs.values(), 830 legend_tiles.geometry, 831 legend_tiles.rotation): 832 c = tile.centroid 833 ax.annotate(cs["column"], xy = (c.x, c.y), 834 ha = "center", va = "center", 835 rotation_mode = "anchor", 836 # adjust rotation to favour text reading left to right 837 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 838 bbox = {"lw": 0, "fc": "#ffffff60"}) 839 840 # now plot background; we include the central tiles, since in 841 # the weave case these may not match the legend tiles 842 context_tiles = self.tiling.tileable \ 843 .get_local_patch(r = 2, include_0 = True) \ 844 .geometry.rotate(self.tiling.rotation, origin = (0, 0)) 845 if self.use_ellipse: 846 context_tiles.clip(ellipse, keep_geom_type = False).plot( 847 ax = ax, fc = "#9F9F9F3F", lw = .35) 848 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 849 ellipse, keep_geom_type = True).plot( 850 ax = ax, ec = "#5F5F5F", lw = .35) 851 else: 852 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", 853 ec = "#5F5F5F", lw = .35) 854 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 855 ax = ax, ec = "#5F5F5F", lw = .35) 856 857 # plot the legend key tiles (which include the data) 858 self._plot_subsetted_gdf(ax, legend_key, **kwargs) 859 860 861 def _get_legend_key_gdf(self, tiles:gpd.GeoDataFrame) -> gpd.GeoDataFrame: 862 """Return tiles dissected and with data assigned for use as a legend. 863 864 'Dissection' is handled differently by `WeaveUnit` and `TileUnit` 865 objects and delegated to either `WeaveUnit._get_legend_key_shapes()` 866 or `TileUnit._get_legend_key_shapes()`. 867 868 Args: 869 tiles (gpd.GeoDataFrame): the legend tiles. 870 871 Returns: 872 gpd.GeoDataFrame: with tile_id, variables and rotation 873 attributes, and geometries of Tileable tiles sliced into a 874 colour ramp or set of nested tiles. 875 876 """ 877 key_tiles = [] # set of tiles to form a colour key (e.g. a ramp) 878 ids = [] # tile_ids applied to the keys 879 unique_ids = [] # list of each tile_id used in order 880 vals = [] # the data assigned to the key tiles 881 rots = [] # rotation of each key tile 882 # subsets = self.map.groupby("tile_id") 883 for (id, cspec), geom, rot in zip(self._colourspecs.items(), 884 tiles.geometry, 885 tiles.rotation): 886 d = list(self.map.loc[self.map.tile_id == id][cspec["column"]]) 887 # if the data are categorical then it's complicated... 888 # if cs["categorical"]: 889 # radial = True and self.radial_key 890 # # desired order of categorical variable is the 891 # # color maps dictionary keys 892 # num_cats = len(cmap) 893 # val_order = dict(zip(cmap.keys(), range(num_cats))) 894 # # compile counts of each category 895 # freqs = [0] * num_cats 896 # for v in list(d): 897 # freqs[val_order[v]] += 1 898 # # make list of the categories containing appropriate 899 # # counts of each in the order needed using a reverse lookup 900 # data_vals = list(val_order.keys()) 901 # data_vals = [data_vals[i] for i, f in enumerate(freqs) if f > 0] 902 # else: # any other data is easy! 903 # data_vals = sorted(d) 904 # freqs = [1] * len(data_vals) 905 data_vals = sorted(d) 906 key = self.tiling.tileable._get_legend_key_shapes( # type: ignore 907 geom, [1] * len(data_vals), rot, False) 908 key_tiles.extend(key) 909 vals.extend(data_vals) 910 n = len(data_vals) 911 ids.extend([id] * n) 912 unique_ids.append(id) 913 rots.extend([rot] * n) 914 # finally make up a data table with all the data in all the columns. This 915 # allows us to reuse the tiling_utils.plot_subsetted_gdf() function. To be 916 # clear: all the data from all variables are added in all columns but when 917 # sent for plotting only the subset associated with each tile_id will get 918 # plotted. It's wasteful of space... but note that the same is true of the 919 # original data - each tile_id has data for all the variables even if it's 920 # not being used to plot them: tables gonna table! 921 key_data = {} 922 for ID in unique_ids: 923 key_data[self.vars_to_map[self.ids_to_map.index(ID)]] = vals 924 key_gdf = gpd.GeoDataFrame( 925 data = key_data | {"tile_id": ids, "rotation": rots}, 926 crs = self.map.crs, 927 geometry = gpd.GeoSeries(key_tiles)) 928 key_gdf.geometry = key_gdf.rotate(self.tiling.rotation, origin = (0, 0)) 929 return key_gdf 930 931 932 def explore(self) -> None: 933 """TODO: add wrapper to make tiled web map via geopandas.explore. 934 """ 935 return None 936 937 938 def _set_colourspecs(self) -> None: 939 """Set _colourspecs dictionary based on instance member variables. 940 941 Set up is to the extent it is possible to follow user requested variables. 942 Each requested `ids_to_map` item keys a dictionary in `_colourspecs` which 943 contains the `column`, `cmap`, `scheme`, `categorical`, and `k` parameters 944 to be passed on for use by the `geopandas.GeoDataFrame.plot()` calls in the 945 _plot_subsetted_gdf()` method. 946 947 This is the place to make 'smart' adjustments to how user requests for map 948 styling are handled. 949 """ 950 numeric_columns = list(self.map.select_dtypes( 951 include = ("float", "int")).columns) 952 # note that some numeric columns can be considered categorical 953 categorical_columns = list(self.map.select_dtypes( 954 include = ("category", "int")).columns) 955 try: 956 if isinstance(self.ids_to_map, str): 957 # wrap a single string in a list - this would be an unusual request... 958 if self.ids_to_map in list(self.map.tile_id): 959 print("""You have only requested a single attribute to map. 960 That's fine, but perhaps not what you intended?""") 961 self.ids_to_map = [self.ids_to_map] 962 else: 963 raise KeyError( 964 """You have requested a single non-existent attribute to map!""") 965 elif self.ids_to_map is None or not isinstance(self.ids_to_map, Iterable): 966 # default to using all of them in order 967 print("""No tile ids provided: setting all of them!""") 968 self.ids_to_map = sorted(list(set(self.map.tile_id))) 969 970 if self.vars_to_map is None or not isinstance(self.vars_to_map, Iterable): 971 self.vars_to_map = [] 972 if len(numeric_columns) == 0: 973 # if there are none then we can't do it 974 raise IndexError("""Attempting to set default variables, but 975 there are no numeric columns in the data!""") 976 if len(numeric_columns) < len(self.ids_to_map): 977 # if there are fewer available than we need then repeat some 978 print("""Fewer numeric columns in the data than elements in the 979 tile unit. Reusing as many as needed to make up the numbers""") 980 reps = len(self.ids_to_map) // len(numeric_columns) + 1 981 self.vars_to_map = (numeric_columns * reps)[:len(self.ids_to_map)] 982 elif len(numeric_columns) > len(self.ids_to_map): 983 # if there are more than we need let the user know, but trim list 984 print("""Note that you have supplied more variables to map than 985 there are distinct elements in the tile unit. Ignoring the 986 extras.""") 987 self.vars_to_map = numeric_columns[:len(self.ids_to_map)] 988 else: 989 self.vars_to_map = numeric_columns 990 # print(f"{self.vars_to_map=}") 991 992 if self.categoricals is None or not isinstance(self.categoricals, Iterable): 993 # provide a set of defaults 994 self.categoricals = [col not in numeric_columns for col in self.vars_to_map] 995 # print(f"{self.categoricals=}") 996 997 if isinstance(self.schemes_to_use, str): 998 self.schemes_to_use = [self.schemes_to_use] * len(self.ids_to_map) 999 elif self.schemes_to_use is None or not isinstance(self.schemes_to_use, Iterable): 1000 # provide a set of defaults 1001 self.schemes_to_use = [None if cat else "EqualInterval" 1002 for cat in self.categoricals] 1003 # print(f"{self.schemes_to_use=}") 1004 1005 if isinstance(self.colors_to_use, str): 1006 self.colors_to_use = [self.colors_to_use] * len(self.ids_to_map) 1007 elif self.colors_to_use is None or not isinstance(self.colors_to_use, Iterable): 1008 # provide starter defaults 1009 print(f"""No colour maps provided! Setting some defaults.""") 1010 self.colors_to_use = ["Set1" if cat else "Reds" for cat in self.categoricals] 1011 for i, (col, cat) in enumerate(zip(self.colors_to_use, self.categoricals)): 1012 if cat and col not in CMAPS_CATEGORICAL: 1013 self.colors_to_use[i] = CMAPS_CATEGORICAL[i] 1014 # we'll allow diverging schemes for now... 1015 elif not cat and col not in CMAPS_SEQUENTIAL and col not in CMAPS_DIVERGING: 1016 self.colors_to_use[i] = CMAPS_SEQUENTIAL[i] 1017 # print(f"{self.colors_to_use=}") 1018 1019 if isinstance(self.n_classes, int): 1020 if self.n_classes == 0: 1021 self.n_classes = [255] * len(self.ids_to_map) 1022 else: 1023 self.n_classes = [self.n_classes] * len(self.ids_to_map) 1024 elif self.n_classes is None or not isinstance(self.n_classes, Iterable): 1025 # provide a set of defaults 1026 self.n_classes = [None if cat else 100 for cat in self.categoricals] 1027 # print(f"{self.n_classes=}") 1028 1029 except IndexError as e: 1030 e.add_note("""One or more of the supplied lists of mapping settings is 1031 an inappropriate length""") 1032 raise 1033 1034 self._colourspecs = { 1035 ID: {"column": v, 1036 "cmap": c, 1037 "categorical": cat, 1038 "scheme": s, 1039 "k": k} 1040 for ID, v, c, cat, s, k 1041 in zip(self.ids_to_map, 1042 self.vars_to_map, 1043 self.colors_to_use, 1044 self.categoricals, 1045 self.schemes_to_use, 1046 self.n_classes, strict = False)}
212@dataclass(slots=True, init=False) 213class Tiling: 214 """Class that applies a `Tileable` object to a region to be mapped. 215 216 The result of the tiling procedure is stored in the `tiles` variable and 217 covers a region sufficient that the tiling can be rotated to any desired 218 angle. Rotation can be requested when the render method is called. 219 """ 220 221 tileable:Tileable 222 """Tileable on which the tiling is based.""" 223 region:gpd.GeoDataFrame 224 """the region to be tiled.""" 225 region_union:geom.Polygon 226 """a single polygon of all the areas in the region to be tiled""" 227 grid:_TileGrid 228 """the grid which will be used to apply the tiling.""" 229 tiles:gpd.GeoDataFrame 230 """the tiles after tiling has been carried out.""" 231 prototiles:gpd.GeoDataFrame 232 """the prototiles after tiling has been carried out.""" 233 rotation:float 234 """additional rotation applied to the tiling beyond any that might have 235 been 'baked in' to the Tileable.""" 236 237 def __init__( 238 self, 239 tileable:Tileable, 240 region:gpd.GeoDataFrame, 241 as_icons:bool = False, 242 ) -> None: 243 """Construct a tiling by polygons extending beyond supplied region. 244 245 The tiling is extended sufficiently to allow for its application at any 246 rotation. 247 248 Args: 249 tileable (Tileable): the TileUnit or WeaveUnit to use. 250 region (gpd.GeoDataFrame): the region to be tiled. 251 as_icons (bool, optional): if True prototiles will only be placed at the 252 region's zone centroids, one per zone. Defaults to False. 253 254 """ 255 self.tileable = tileable 256 self.rotation = 0 257 self.region = region 258 self.region.sindex # this probably speeds up overlay 259 self.region_union = self.region.geometry.union_all() 260 self.grid = _TileGrid( 261 self.tileable, 262 self.region.geometry if as_icons else gpd.GeoSeries([self.region_union]), 263 as_icons) 264 self.tiles, self.prototiles = self.make_tiling() 265 self.tiles.sindex # again this probably speeds up overlay 266 267 268 def get_tiled_map( 269 self, 270 rotation:float = 0., 271 join_on_prototiles:bool = False, 272 retain_tileables:bool = False, 273 prioritise_tiles:bool = True, 274 ragged_edges:bool = True, 275 use_centroid_lookup_approximation:bool = False, 276 debug:bool = False, 277 ) -> TiledMap: 278 """Return a `TiledMap` filling a region at the requested rotation. 279 280 HERE BE DRAGONS! This function took a lot of trial and error to get right, 281 so modify with CAUTION! 282 283 The `proritise_tiles = True` option means that the tiling will not break 284 up the tiles in `TileUnit`s at the boundaries between areas in the mapped 285 region, but will instead ensure that tiles remain complete, picking up 286 their data from the region zone which they overlap the most. 287 288 The exact order in which operations are performed affects performance. For 289 example, the final clipping to self.region when ragged_edges = False is 290 _much_ slower if it is carried out before the dissolving of tiles into the 291 region zones. So... again... modify CAREFULLY! 292 293 Args: 294 rotation (float, optional): Optional rotation to apply. Defaults to 0. 295 join_on_prototiles (bool, optional): if True data from the region dataset 296 are joined to tiles based on the prototile to which they belong. If 297 False the join is based on the tiles in relation to the region areas. 298 For weave-based tilings False is probably to be preferred. Defaults to 299 False. 300 retain_tileables (bool, optional): if True complete tileable units will 301 be retained. If False tile unit elements that do not overlap the map 302 area will be discarded. 303 prioritise_tiles (bool, optional): if True tiles will not be broken at 304 boundaries in the region dataset. Defaults to True. 305 ragged_edges (bool, optional): if True tiles at the edge of the region 306 will not be cut by the region extent - ignored if prioritise_tiles is 307 False when edges will always be clipped to the region extent. Defaults 308 to True. 309 use_centroid_lookup_approximation (bool, optional): if True use tile 310 centroids for lookup of region data - ignored if prioritise_tiles is 311 False when it is irrelevant. Defaults to False. 312 debug (bool, optional): if True prints timing messages. Defaults to 313 False. 314 315 Returns: 316 TiledMap: a TiledMap of the region with attributes attached to tiles. 317 318 """ 319 if debug: 320 t1 = perf_counter() 321 322 id_var = self._setup_region_DZID() 323 if join_on_prototiles: 324 if rotation == 0: 325 tiled_map, join_layer = self.tiles, self.prototiles 326 else: 327 tiled_map, join_layer = self.rotated(rotation) 328 tiled_map["joinUID"] = self.tiles["prototile_id"] 329 else: 330 tiled_map = self.tiles if rotation == 0 else self.rotated(rotation)[0] 331 tiled_map["joinUID"] = self.tiles["tile_id"] 332 join_layer = tiled_map 333 join_layer["joinUID"] = list(range(join_layer.shape[0])) 334 335 # compile a list of the variable names we are NOT going to change 336 # i.e. everything except the geometry and the id_var 337 region_vars = [column for column in self.region.columns 338 if "geom" not in column and column != id_var] 339 340 if debug: 341 t2 = perf_counter() 342 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 343 344 if prioritise_tiles: 345 # maintain tile continuity across zone boundaries 346 # so we have to do more work than a simple overlay 347 if use_centroid_lookup_approximation: 348 t5 = perf_counter() 349 tile_pts = copy.deepcopy(join_layer) 350 tile_pts.geometry = tile_pts.centroid 351 lookup = tile_pts.sjoin( 352 self.region, how = "inner")[["joinUID", id_var]] 353 else: 354 # determine areas of overlapping tiles and drop the data we join the 355 # data back later, so dropping makes that easier overlaying in region. 356 # overlay(tiles) seems to be faster?? 357 # TODO: also... this part is performance-critical, think about fixes -- 358 # possibly including the above centroid-based approximation 359 overlaps = self.region.overlay(join_layer, make_valid = False) 360 if debug: 361 t3 = perf_counter() 362 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 363 overlaps["area"] = overlaps.geometry.area 364 if debug: 365 t4 = perf_counter() 366 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 367 overlaps.drop(columns = region_vars, inplace = True) 368 if debug: 369 t5 = perf_counter() 370 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 371 # make a lookup by largest area tile to region id 372 lookup = overlaps \ 373 .iloc[overlaps.groupby("joinUID")["area"] \ 374 .agg(pd.Series.idxmax)][["joinUID", id_var]] 375 # now join the lookup and from there the region data 376 if debug: 377 t6 = perf_counter() 378 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 379 tiled_map = tiled_map \ 380 .merge(lookup, on = "joinUID") \ 381 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 382 if debug: 383 t7 = perf_counter() 384 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 385 tiled_map.drop(columns = ["joinUID"], inplace = True) 386 387 else: 388 # here it's a simple overlay 389 tiled_map = self.region.overlay(tiled_map) 390 t7 = perf_counter() 391 if debug: 392 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 393 394 if not retain_tileables: 395 tiled_map = tiled_map.loc[ 396 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 397 398 tiled_map.drop(columns = [id_var], inplace = True) 399 self.region.drop(columns = [id_var], inplace = True) 400 401 # if we've retained tiles and want 'clean' edges, then clip 402 # note that this step is slow: geopandas unary_unions the clip layer 403 if prioritise_tiles and not ragged_edges: 404 tiled_map.sindex 405 tiled_map = tiled_map.clip(self.region_union) 406 if debug: 407 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 408 409 tm = TiledMap() 410 tm.tiling = self 411 tm.map = tiled_map 412 return tm 413 414 415 def _setup_region_DZID(self) -> str: 416 """Return guaranteed-unique new attribute name for self.region dataframe. 417 418 Avoids a name clash with any existing attribute in the dataframe. 419 420 Returns: 421 str: name of the added attribute. 422 423 """ 424 dzid = "DZID" 425 i = 0 426 while dzid in self.region.columns: 427 dzid = "DZID" + str(i) 428 i = i + 1 429 self.region[dzid] = list(range(self.region.shape[0])) 430 return dzid 431 432 433 def make_tiling(self) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 434 """Tile the region with a tile unit, returning a GeoDataFrame. 435 436 Returns: 437 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 438 tile unit. 439 440 """ 441 # we assume the geometry column is called geometry so make it so... 442 if self.region.geometry.name != "geometry": 443 self.region.rename_geometry("geometry", inplace = True) 444 445 # chain list of lists of GeoSeries geometries to list of geometries 446 tiles = itertools.chain(*[ 447 self.tileable.tiles.geometry.translate(p.x, p.y) 448 for p in self.grid.points]) 449 prototiles = itertools.chain(*[ 450 self.tileable.prototile.geometry.translate(p.x, p.y) 451 for p in self.grid.points]) 452 # replicate the tile ids 453 tile_ids = list(self.tileable.tiles.tile_id) * len(self.grid.points) 454 prototile_ids = list(range(len(self.grid.points))) 455 tile_prototile_ids = sorted(prototile_ids * self.tileable.tiles.shape[0]) 456 tiles_gs = gpd.GeoSeries(list(tiles)) 457 prototiles_gs = gpd.GeoSeries(list(prototiles)) 458 # assemble and return as GeoDataFrames 459 tiles_gdf = gpd.GeoDataFrame( 460 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 461 geometry = tiles_gs, crs = self.tileable.crs) 462 prototiles_gdf = gpd.GeoDataFrame( 463 data = {"prototile_id": prototile_ids}, 464 geometry = prototiles_gs, crs = self.tileable.crs) 465 return tiles_gdf, prototiles_gdf 466 467 468 def rotated(self, 469 rotation:float = 0.0, 470 ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 471 """Return the stored tiling rotated. 472 473 The stored tiling never changes and 474 if it was originally made with a Tileable that was rotated it will retain 475 that rotation. The requested rotation is _additional_ to that baseline 476 rotation. 477 478 Args: 479 rotation (float, optional): Rotation angle in degrees. 480 Defaults to None. 481 482 Returns: 483 gpd.GeoDataFrame: Rotated tiling. 484 485 """ 486 if self.tiles is None: 487 self.tiles = self.make_tiling()[0] 488 if rotation == 0: 489 return self.tiles, self.prototiles 490 tiles = gpd.GeoDataFrame( 491 data = {"tile_id": self.tiles.tile_id, 492 "prototile_id": self.tiles.tile_id}, 493 crs = self.tiles.crs, 494 geometry = self.tiles.geometry.rotate( 495 rotation, origin = self.grid.centre)) 496 prototiles = gpd.GeoDataFrame( 497 data = {"prototile_id": self.prototiles.prototile_id}, 498 crs = self.prototiles.crs, 499 geometry = self.prototiles.geometry.rotate( 500 rotation, origin = self.grid.centre)) 501 self.rotation = rotation 502 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. Rotation can be requested when the render method is called.
237 def __init__( 238 self, 239 tileable:Tileable, 240 region:gpd.GeoDataFrame, 241 as_icons:bool = False, 242 ) -> None: 243 """Construct a tiling by polygons extending beyond supplied region. 244 245 The tiling is extended sufficiently to allow for its application at any 246 rotation. 247 248 Args: 249 tileable (Tileable): the TileUnit or WeaveUnit to use. 250 region (gpd.GeoDataFrame): the region to be tiled. 251 as_icons (bool, optional): if True prototiles will only be placed at the 252 region's zone centroids, one per zone. Defaults to False. 253 254 """ 255 self.tileable = tileable 256 self.rotation = 0 257 self.region = region 258 self.region.sindex # this probably speeds up overlay 259 self.region_union = self.region.geometry.union_all() 260 self.grid = _TileGrid( 261 self.tileable, 262 self.region.geometry if as_icons else gpd.GeoSeries([self.region_union]), 263 as_icons) 264 self.tiles, self.prototiles = self.make_tiling() 265 self.tiles.sindex # again this probably speeds up overlay
Construct a tiling by polygons extending beyond supplied region.
The tiling is extended sufficiently to allow for its application at any rotation.
Args: tileable (Tileable): the TileUnit or WeaveUnit to use. region (gpd.GeoDataFrame): the region to be tiled. as_icons (bool, optional): if True prototiles will only be placed at the region's zone centroids, one per zone. Defaults to False.
a single polygon of all the areas in the region to be tiled
additional rotation applied to the tiling beyond any that might have been 'baked in' to the Tileable.
268 def get_tiled_map( 269 self, 270 rotation:float = 0., 271 join_on_prototiles:bool = False, 272 retain_tileables:bool = False, 273 prioritise_tiles:bool = True, 274 ragged_edges:bool = True, 275 use_centroid_lookup_approximation:bool = False, 276 debug:bool = False, 277 ) -> TiledMap: 278 """Return a `TiledMap` filling a region at the requested rotation. 279 280 HERE BE DRAGONS! This function took a lot of trial and error to get right, 281 so modify with CAUTION! 282 283 The `proritise_tiles = True` option means that the tiling will not break 284 up the tiles in `TileUnit`s at the boundaries between areas in the mapped 285 region, but will instead ensure that tiles remain complete, picking up 286 their data from the region zone which they overlap the most. 287 288 The exact order in which operations are performed affects performance. For 289 example, the final clipping to self.region when ragged_edges = False is 290 _much_ slower if it is carried out before the dissolving of tiles into the 291 region zones. So... again... modify CAREFULLY! 292 293 Args: 294 rotation (float, optional): Optional rotation to apply. Defaults to 0. 295 join_on_prototiles (bool, optional): if True data from the region dataset 296 are joined to tiles based on the prototile to which they belong. If 297 False the join is based on the tiles in relation to the region areas. 298 For weave-based tilings False is probably to be preferred. Defaults to 299 False. 300 retain_tileables (bool, optional): if True complete tileable units will 301 be retained. If False tile unit elements that do not overlap the map 302 area will be discarded. 303 prioritise_tiles (bool, optional): if True tiles will not be broken at 304 boundaries in the region dataset. Defaults to True. 305 ragged_edges (bool, optional): if True tiles at the edge of the region 306 will not be cut by the region extent - ignored if prioritise_tiles is 307 False when edges will always be clipped to the region extent. Defaults 308 to True. 309 use_centroid_lookup_approximation (bool, optional): if True use tile 310 centroids for lookup of region data - ignored if prioritise_tiles is 311 False when it is irrelevant. Defaults to False. 312 debug (bool, optional): if True prints timing messages. Defaults to 313 False. 314 315 Returns: 316 TiledMap: a TiledMap of the region with attributes attached to tiles. 317 318 """ 319 if debug: 320 t1 = perf_counter() 321 322 id_var = self._setup_region_DZID() 323 if join_on_prototiles: 324 if rotation == 0: 325 tiled_map, join_layer = self.tiles, self.prototiles 326 else: 327 tiled_map, join_layer = self.rotated(rotation) 328 tiled_map["joinUID"] = self.tiles["prototile_id"] 329 else: 330 tiled_map = self.tiles if rotation == 0 else self.rotated(rotation)[0] 331 tiled_map["joinUID"] = self.tiles["tile_id"] 332 join_layer = tiled_map 333 join_layer["joinUID"] = list(range(join_layer.shape[0])) 334 335 # compile a list of the variable names we are NOT going to change 336 # i.e. everything except the geometry and the id_var 337 region_vars = [column for column in self.region.columns 338 if "geom" not in column and column != id_var] 339 340 if debug: 341 t2 = perf_counter() 342 print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}") 343 344 if prioritise_tiles: 345 # maintain tile continuity across zone boundaries 346 # so we have to do more work than a simple overlay 347 if use_centroid_lookup_approximation: 348 t5 = perf_counter() 349 tile_pts = copy.deepcopy(join_layer) 350 tile_pts.geometry = tile_pts.centroid 351 lookup = tile_pts.sjoin( 352 self.region, how = "inner")[["joinUID", id_var]] 353 else: 354 # determine areas of overlapping tiles and drop the data we join the 355 # data back later, so dropping makes that easier overlaying in region. 356 # overlay(tiles) seems to be faster?? 357 # TODO: also... this part is performance-critical, think about fixes -- 358 # possibly including the above centroid-based approximation 359 overlaps = self.region.overlay(join_layer, make_valid = False) 360 if debug: 361 t3 = perf_counter() 362 print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}") 363 overlaps["area"] = overlaps.geometry.area 364 if debug: 365 t4 = perf_counter() 366 print(f"STEP A3: calculate areas: {t4 - t3:.3f}") 367 overlaps.drop(columns = region_vars, inplace = True) 368 if debug: 369 t5 = perf_counter() 370 print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}") 371 # make a lookup by largest area tile to region id 372 lookup = overlaps \ 373 .iloc[overlaps.groupby("joinUID")["area"] \ 374 .agg(pd.Series.idxmax)][["joinUID", id_var]] 375 # now join the lookup and from there the region data 376 if debug: 377 t6 = perf_counter() 378 print(f"STEP A5: build lookup for join: {t6 - t5:.3f}") 379 tiled_map = tiled_map \ 380 .merge(lookup, on = "joinUID") \ 381 .merge(self.region.drop(columns = ["geometry"]), on = id_var) 382 if debug: 383 t7 = perf_counter() 384 print(f"STEP A6: perform lookup join: {t7 - t6:.3f}") 385 tiled_map.drop(columns = ["joinUID"], inplace = True) 386 387 else: 388 # here it's a simple overlay 389 tiled_map = self.region.overlay(tiled_map) 390 t7 = perf_counter() 391 if debug: 392 print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}") 393 394 if not retain_tileables: 395 tiled_map = tiled_map.loc[ 396 shapely.intersects(self.region_union, np.array(tiled_map.geometry)), :] 397 398 tiled_map.drop(columns = [id_var], inplace = True) 399 self.region.drop(columns = [id_var], inplace = True) 400 401 # if we've retained tiles and want 'clean' edges, then clip 402 # note that this step is slow: geopandas unary_unions the clip layer 403 if prioritise_tiles and not ragged_edges: 404 tiled_map.sindex 405 tiled_map = tiled_map.clip(self.region_union) 406 if debug: 407 print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""") 408 409 tm = TiledMap() 410 tm.tiling = self 411 tm.map = tiled_map 412 return tm
Return 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): 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 False. retain_tileables (bool, optional): if True complete tileable units will be retained. If False tile unit elements that do not overlap the map area will be discarded. 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 region with attributes attached to tiles.
433 def make_tiling(self) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 434 """Tile the region with a tile unit, returning a GeoDataFrame. 435 436 Returns: 437 geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the 438 tile unit. 439 440 """ 441 # we assume the geometry column is called geometry so make it so... 442 if self.region.geometry.name != "geometry": 443 self.region.rename_geometry("geometry", inplace = True) 444 445 # chain list of lists of GeoSeries geometries to list of geometries 446 tiles = itertools.chain(*[ 447 self.tileable.tiles.geometry.translate(p.x, p.y) 448 for p in self.grid.points]) 449 prototiles = itertools.chain(*[ 450 self.tileable.prototile.geometry.translate(p.x, p.y) 451 for p in self.grid.points]) 452 # replicate the tile ids 453 tile_ids = list(self.tileable.tiles.tile_id) * len(self.grid.points) 454 prototile_ids = list(range(len(self.grid.points))) 455 tile_prototile_ids = sorted(prototile_ids * self.tileable.tiles.shape[0]) 456 tiles_gs = gpd.GeoSeries(list(tiles)) 457 prototiles_gs = gpd.GeoSeries(list(prototiles)) 458 # assemble and return as GeoDataFrames 459 tiles_gdf = gpd.GeoDataFrame( 460 data = {"tile_id": tile_ids, "prototile_id": tile_prototile_ids}, 461 geometry = tiles_gs, crs = self.tileable.crs) 462 prototiles_gdf = gpd.GeoDataFrame( 463 data = {"prototile_id": prototile_ids}, 464 geometry = prototiles_gs, crs = self.tileable.crs) 465 return tiles_gdf, prototiles_gdf
Tile the region with a tile unit, returning a GeoDataFrame.
Returns: geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the tile unit.
468 def rotated(self, 469 rotation:float = 0.0, 470 ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: 471 """Return the stored tiling rotated. 472 473 The stored tiling never changes and 474 if it was originally made with a Tileable that was rotated it will retain 475 that rotation. The requested rotation is _additional_ to that baseline 476 rotation. 477 478 Args: 479 rotation (float, optional): Rotation angle in degrees. 480 Defaults to None. 481 482 Returns: 483 gpd.GeoDataFrame: Rotated tiling. 484 485 """ 486 if self.tiles is None: 487 self.tiles = self.make_tiling()[0] 488 if rotation == 0: 489 return self.tiles, self.prototiles 490 tiles = gpd.GeoDataFrame( 491 data = {"tile_id": self.tiles.tile_id, 492 "prototile_id": self.tiles.tile_id}, 493 crs = self.tiles.crs, 494 geometry = self.tiles.geometry.rotate( 495 rotation, origin = self.grid.centre)) 496 prototiles = gpd.GeoDataFrame( 497 data = {"prototile_id": self.prototiles.prototile_id}, 498 crs = self.prototiles.crs, 499 geometry = self.prototiles.geometry.rotate( 500 rotation, origin = self.grid.centre)) 501 self.rotation = rotation 502 return tiles, prototiles
Return the stored tiling rotated.
The stored tiling never changes and if it was originally made with a Tileable that was rotated it will retain that rotation. The requested rotation is _additional_ to that baseline rotation.
Args: rotation (float, optional): Rotation angle in degrees. Defaults to None.
Returns: gpd.GeoDataFrame: Rotated tiling.
505@dataclass(slots=True) 506class TiledMap: 507 """Class representing a tiled map. 508 509 Should not be accessed directly, but will 510 be created by calling `Tiling.get_tiled_map()`. After creation the variables 511 and colourmaps attributes can be set, and then `TiledMap.render()` called to 512 make a map. Settable attributes are explained in documentation of the 513 `TiledMap.render()` method. 514 515 Examples: 516 Recommended usage is as follows. First, make a `TiledMap` from a `Tiling` 517 object: 518 519 `tm = tiling.get_tiled_map(...)` 520 521 Some options in the `Tiling` constructor affect the map appearance. See 522 `Tiling` for details. 523 524 Once a `TiledMap` object exists, set options on it, either when calling 525 `TiledMap.render()` or explicitly, i.e. 526 527 tm.render(opt1 = val1, opt2 = val2, ...) 528 529 or 530 531 tm.opt1 = val1 532 tm.opt2 = val2 533 tm.render() 534 535 Option settings are persistent, i.e. unless a new `TiledMap` object is 536 created the option settings have to be explicitly reset to new values on 537 subsequent calls to `TiledMap.render()`. 538 539 The most important options are the `vars_map` and `colors_to_use` settings. 540 541 `vars_to_map` is a lost of the dataset variable names to match with 542 `weavingspace.tileable.Tileable` elements with corresponding (ordered) 543 tile_ids (usually "a", "b", etc.). If you need to control the match, then 544 also supply `ids_to_map` in matching order. E.g. 545 546 tm.ids_to_map = ['d', 'c', 'b', 'a'] 547 tm.vars_to_map = ['x1', 'x2', 'x3', 'x4'] 548 549 Note that this means that if you really want more than one element in the 550 tiling to represent the same variable more than once, you can do that. 551 552 `colors_to_use` is a parallel list of named matplotlib colormaps, 553 554 tm.colors_to_use = ["Reds", "Blues", "Greys", "Purples"] 555 556 Similarly, you can specify the classification `schemes_to_use` (such as 557 'quantiles') and the number of classes `n_classes` in each. 558 559 If data are categorical, this is flagged in the `categoricals` list of 560 booleans, in which case an appropriate colour map should be used. There is 561 currently no provision for control of which colour in a categorical 562 colour map is applied to which variable level. 563 564 TODO: better control of categorical mapping schemes. 565 566 """ 567 568 # these will be set at instantion by Tiling.get_tiled_map() 569 tiling:Tiling = None 570 """the Tiling with the required tiles""" 571 map:gpd.GeoDataFrame = None 572 """the GeoDataFrame on which this map is based""" 573 ids_to_map:list[str] = None 574 """tile_ids that are to be used to represent data""" 575 vars_to_map:list[str] = None 576 """dataset variables that are to be symbolised""" 577 colors_to_use:list[str|list[str]] = None 578 """list of matplotlib colormap names.""" 579 categoricals:list[bool] = None 580 """list specifying if each variable is -- or is to be treated as -- 581 categorical""" 582 schemes_to_use:list[str|None] = None 583 """mapclassify schemes to use for each variable.""" 584 n_classes:list[int|None] = None 585 """number of classes to apply; if set to 0 will be unclassed.""" 586 _colourspecs:dict[str,dict] = None 587 """dictionary of dictionaries keyed by the items in `ids_to_use` with each 588 dictionary forming additional kwargs to be supplied to geopandas.plot().""" 589 590 # the below parameters can be set either before calling self.render() or 591 # passed in as parameters to self.render(). These are solely 592 # `TiledMap.render()` options not geopandas plot options. 593 legend:bool = True 594 """whether or not to show a legend""" 595 legend_zoom:float = 1.0 596 """<1 zooms out from legend to show more context""" 597 legend_dx:float = 0. 598 """x shift of legend relative to the map""" 599 legend_dy:float = 0. 600 """y shift of legend relative to the map""" 601 use_ellipse:bool = False 602 """if True clips legend with an ellipse""" 603 ellipse_magnification:float = 1.0 604 """magnification to apply to clip ellipse""" 605 radial_key:bool = False 606 """if True use radial key even for ordinal/ratio data (normally these will be 607 shown by concentric tile geometries)""" 608 draft_mode:bool = False 609 """if True plot the map coloured by tile_id""" 610 611 # the parameters below are geopandas.plot options which we intercept to 612 # ensure they are applied appropriately when we plot a GDF 613 figsize:tuple[float,float] = (20, 15) 614 """maptlotlib figsize""" 615 dpi:float = 72 616 """dpi for bitmap formats""" 617 618 def render( 619 self, 620 **kwargs, 621 ) -> Figure: 622 """Render the current state to a map. 623 624 Note that TiledMap objects will usually be created by calling 625 `Tiling.get_tiled_map()`. 626 627 Args: 628 ids_to_map (list[str]): tile_ids to be used in the map. Defaults to None. 629 vars_to_map (list[str]): dataset columns to be mapped. Defaults to None. 630 colors_to_use (list[str]): list of matplotlib colormaps to be used. 631 Defaults to None. 632 categoricals (list[bool]): list of flags indicating if associated variable 633 should be treated as categorical. Defaults to None. 634 schemes_to_use (list[str]): list of strings indicating the mapclassify 635 scheme to use e.g. 'equalinterval' or 'quantiles'. Defaults to None. 636 n_classes (list[int]): list of ints indicating number of classes to use in 637 map classification. Defaults to None. 638 legend (bool): If True a legend will be drawn. Defaults to True. 639 legend_zoom (float): Zoom factor to apply to the legend. Values <1 640 will show more of the tile context. Defaults to 1.0. 641 legend_dx (float): x shift to apply to the legend position in plot area 642 relative units, i.e. 1.0 is full width of plot. Defaults to 0.0. 643 legend_dy (float): x and y shift to apply to the legend position in plot 644 area relative units, i.e. 1.0 is full height of plot. Defaults to 0.0. 645 use_ellipse (bool): If True applies an elliptical clip to the legend. 646 Defaults to False. 647 ellipse_magnification (float): Magnification to apply to ellipse clipped 648 legend. Defaults to 1.0. 649 radial_key (bool): If True legend key for TileUnit maps will be based on 650 radially dissecting the tiles, i.e. pie slices. Defaults to False. 651 draft_mode (bool): If True a map of the tiled map coloured by tile_ids 652 (and with no legend) is returned. Defaults to False. 653 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 654 plot. Defaults to (20, 15). 655 dpi (float): passed to pyplot.plot. Defaults to 72. 656 **kwargs: other settings to pass to pyplot/geopandas.plot. 657 658 Returns: 659 matplotlib.figure.Figure: figure on which map is plotted. 660 661 """ 662 plt.rcParams["pdf.fonttype"] = 42 663 plt.rcParams["pdf.use14corefonts"] = True 664 matplotlib.rcParams["pdf.fonttype"] = 42 665 666 to_remove = set() # keep track of kwargs we use to setup TiledMap 667 # kwargs with no corresponding class attribute will be discarded 668 # because we are using slots, we have to use setattr() here 669 for k, v in kwargs.items(): 670 if k in self.__slots__: 671 setattr(self, k, v) 672 to_remove.add(k) 673 # remove any them so we don't pass them on to pyplot and get errors 674 for k in to_remove: 675 del kwargs[k] 676 677 if self.draft_mode: 678 fig = plt.figure(figsize = self.figsize) 679 ax = fig.add_subplot(111) 680 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", **kwargs) 681 ax.set_axis_off() 682 return fig 683 684 if self.legend: 685 # this sizing stuff is rough and ready for now, possibly forever... 686 reg_w, reg_h, *_ = \ 687 tiling_utils.get_width_height_left_bottom(self.map.geometry) 688 tile_w, tile_h, *_ = \ 689 tiling_utils.get_width_height_left_bottom( 690 self.tiling.tileable._get_legend_tiles().rotate( 691 self.tiling.rotation, origin = (0, 0))) 692 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 693 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 694 "width_ratios": [reg_w, sf_w * tile_w]} 695 696 fig, axes = plt.subplot_mosaic([["map", "legend"], ["map", "."]], 697 gridspec_kw = gskw, 698 figsize = self.figsize, 699 layout = "constrained", **kwargs) 700 else: 701 fig, axes = plt.subplots(1, 1, figsize = self.figsize, 702 layout = "constrained", **kwargs) 703 704 self._plot_map(axes, **kwargs) 705 return fig 706 707 708 def _plot_map( 709 self, 710 ax:plt.Axes, 711 **kwargs, 712 ) -> None: 713 """Plot map to the supplied axes. 714 715 Args: 716 axes (plt.Axes): axes on which maps will be drawn. 717 kwargs (dict): additional parameters to be passed to plot. 718 719 """ 720 self._set_colourspecs() 721 bb = self.map.geometry.total_bounds 722 if self.legend: 723 if (self.legend_dx != 0 or self.legend_dx != 0): 724 box = ax["legend"].get_position() 725 box.x0 += self.legend_dx 726 box.x1 += self.legend_dx 727 box.y0 += self.legend_dy 728 box.y1 += self.legend_dy 729 ax["legend"].set_position(box) 730 ax["map"].set_axis_off() 731 ax["map"].set_xlim(bb[0], bb[2]) 732 ax["map"].set_ylim(bb[1], bb[3]) 733 self._plot_subsetted_gdf(ax["map"], self.map, **kwargs) 734 self.plot_legend(ax = ax["legend"], **kwargs) 735 else: 736 ax.set_axis_off() 737 ax.set_xlim(bb[0], bb[2]) 738 ax.set_ylim(bb[1], bb[3]) 739 self._plot_subsetted_gdf(ax, self.map, **kwargs) 740 741 742 def _plot_subsetted_gdf( 743 self, 744 ax:plt.Axes, 745 gdf:gpd.GeoDataFrame, 746 grouping_var:str = "tile_id", 747 **kwargs, 748 ) -> None: 749 """Repeatedly plot a gpd.GeoDataFrame based on a subsetting attribute. 750 751 NOTE: used to plot both the main map _and_ the legend, which is why 752 a separate GeoDataframe is supplied and we don't just use self.map. 753 754 Args: 755 ax (pyplot.Axes): axes to plot to. 756 gdf (gpd.GeoDataFrame): the GeoDataFrame to plot. 757 758 Raises: 759 Exception: if self.colourmaps cannot be parsed exception is raised. 760 761 """ 762 groups = gdf.groupby(grouping_var) 763 for ID, cspec in self._colourspecs.items(): 764 subset = groups.get_group(ID) 765 n_values = len(subset[cspec["column"]].unique()) 766 if not cspec["categorical"] and n_values == 1: 767 print(f""" 768 Only one level in variable {cspec['column']}, replacing requested 769 colour map with single colour fill. 770 """) 771 cspec["color"] = matplotlib.colormaps.get(cspec["cmap"])(0.5) 772 del cspec["column"] 773 del cspec["cmap"] 774 del cspec["scheme"] 775 elif not cspec["categorical"] and n_values < cspec["k"]: 776 cspec["k"] = n_values 777 elif cspec["categorical"]: 778 del cspec["scheme"] 779 subset.plot(ax = ax, **cspec, **kwargs) 780 781 782 def to_file(self, fname:str) -> None: 783 """Output the tiled map to a layered GPKG file. 784 785 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 786 787 Args: 788 fname (str): Filename to write. Defaults to None. 789 790 """ 791 tiling_utils.write_map_to_layers(self.map, fname) 792 793 794 def plot_legend(self, ax, **kwargs) -> None: 795 """Plot a legend for this tiled map. 796 797 Args: 798 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 799 800 """ 801 ax.set_axis_off() 802 legend_tiles = self.tiling.tileable._get_legend_tiles() 803 # this is a bit hacky, but we will apply the rotation to text 804 # annotation so for TileUnits which don't need it, reverse that now 805 if isinstance(self.tiling.tileable, TileUnit): 806 # note that this confuses type hinting because pandas silently assigns 807 # a scalar value to a Series 808 legend_tiles.rotation = -self.tiling.rotation 809 810 legend_key = self._get_legend_key_gdf(legend_tiles) 811 legend_tiles.geometry = legend_tiles.geometry.rotate( 812 self.tiling.rotation, origin = (0, 0)) 813 814 if self.use_ellipse: 815 ellipse = tiling_utils.get_bounding_ellipse( 816 legend_tiles.geometry, mag = self.ellipse_magnification) 817 bb = ellipse.total_bounds 818 c = ellipse.union_all().centroid 819 else: 820 bb = legend_tiles.geometry.total_bounds 821 c = legend_tiles.geometry.union_all().centroid 822 823 # apply legend zoom - NOTE that this must be applied even 824 # if self.legend_zoom is == 1... 825 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 826 c.x + (bb[2] - c.x) / self.legend_zoom) 827 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 828 c.y + (bb[3] - c.y) / self.legend_zoom) 829 830 for cs, tile, rotn in zip(self._colourspecs.values(), 831 legend_tiles.geometry, 832 legend_tiles.rotation): 833 c = tile.centroid 834 ax.annotate(cs["column"], xy = (c.x, c.y), 835 ha = "center", va = "center", 836 rotation_mode = "anchor", 837 # adjust rotation to favour text reading left to right 838 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 839 bbox = {"lw": 0, "fc": "#ffffff60"}) 840 841 # now plot background; we include the central tiles, since in 842 # the weave case these may not match the legend tiles 843 context_tiles = self.tiling.tileable \ 844 .get_local_patch(r = 2, include_0 = True) \ 845 .geometry.rotate(self.tiling.rotation, origin = (0, 0)) 846 if self.use_ellipse: 847 context_tiles.clip(ellipse, keep_geom_type = False).plot( 848 ax = ax, fc = "#9F9F9F3F", lw = .35) 849 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 850 ellipse, keep_geom_type = True).plot( 851 ax = ax, ec = "#5F5F5F", lw = .35) 852 else: 853 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", 854 ec = "#5F5F5F", lw = .35) 855 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 856 ax = ax, ec = "#5F5F5F", lw = .35) 857 858 # plot the legend key tiles (which include the data) 859 self._plot_subsetted_gdf(ax, legend_key, **kwargs) 860 861 862 def _get_legend_key_gdf(self, tiles:gpd.GeoDataFrame) -> gpd.GeoDataFrame: 863 """Return tiles dissected and with data assigned for use as a legend. 864 865 'Dissection' is handled differently by `WeaveUnit` and `TileUnit` 866 objects and delegated to either `WeaveUnit._get_legend_key_shapes()` 867 or `TileUnit._get_legend_key_shapes()`. 868 869 Args: 870 tiles (gpd.GeoDataFrame): the legend tiles. 871 872 Returns: 873 gpd.GeoDataFrame: with tile_id, variables and rotation 874 attributes, and geometries of Tileable tiles sliced into a 875 colour ramp or set of nested tiles. 876 877 """ 878 key_tiles = [] # set of tiles to form a colour key (e.g. a ramp) 879 ids = [] # tile_ids applied to the keys 880 unique_ids = [] # list of each tile_id used in order 881 vals = [] # the data assigned to the key tiles 882 rots = [] # rotation of each key tile 883 # subsets = self.map.groupby("tile_id") 884 for (id, cspec), geom, rot in zip(self._colourspecs.items(), 885 tiles.geometry, 886 tiles.rotation): 887 d = list(self.map.loc[self.map.tile_id == id][cspec["column"]]) 888 # if the data are categorical then it's complicated... 889 # if cs["categorical"]: 890 # radial = True and self.radial_key 891 # # desired order of categorical variable is the 892 # # color maps dictionary keys 893 # num_cats = len(cmap) 894 # val_order = dict(zip(cmap.keys(), range(num_cats))) 895 # # compile counts of each category 896 # freqs = [0] * num_cats 897 # for v in list(d): 898 # freqs[val_order[v]] += 1 899 # # make list of the categories containing appropriate 900 # # counts of each in the order needed using a reverse lookup 901 # data_vals = list(val_order.keys()) 902 # data_vals = [data_vals[i] for i, f in enumerate(freqs) if f > 0] 903 # else: # any other data is easy! 904 # data_vals = sorted(d) 905 # freqs = [1] * len(data_vals) 906 data_vals = sorted(d) 907 key = self.tiling.tileable._get_legend_key_shapes( # type: ignore 908 geom, [1] * len(data_vals), rot, False) 909 key_tiles.extend(key) 910 vals.extend(data_vals) 911 n = len(data_vals) 912 ids.extend([id] * n) 913 unique_ids.append(id) 914 rots.extend([rot] * n) 915 # finally make up a data table with all the data in all the columns. This 916 # allows us to reuse the tiling_utils.plot_subsetted_gdf() function. To be 917 # clear: all the data from all variables are added in all columns but when 918 # sent for plotting only the subset associated with each tile_id will get 919 # plotted. It's wasteful of space... but note that the same is true of the 920 # original data - each tile_id has data for all the variables even if it's 921 # not being used to plot them: tables gonna table! 922 key_data = {} 923 for ID in unique_ids: 924 key_data[self.vars_to_map[self.ids_to_map.index(ID)]] = vals 925 key_gdf = gpd.GeoDataFrame( 926 data = key_data | {"tile_id": ids, "rotation": rots}, 927 crs = self.map.crs, 928 geometry = gpd.GeoSeries(key_tiles)) 929 key_gdf.geometry = key_gdf.rotate(self.tiling.rotation, origin = (0, 0)) 930 return key_gdf 931 932 933 def explore(self) -> None: 934 """TODO: add wrapper to make tiled web map via geopandas.explore. 935 """ 936 return None 937 938 939 def _set_colourspecs(self) -> None: 940 """Set _colourspecs dictionary based on instance member variables. 941 942 Set up is to the extent it is possible to follow user requested variables. 943 Each requested `ids_to_map` item keys a dictionary in `_colourspecs` which 944 contains the `column`, `cmap`, `scheme`, `categorical`, and `k` parameters 945 to be passed on for use by the `geopandas.GeoDataFrame.plot()` calls in the 946 _plot_subsetted_gdf()` method. 947 948 This is the place to make 'smart' adjustments to how user requests for map 949 styling are handled. 950 """ 951 numeric_columns = list(self.map.select_dtypes( 952 include = ("float", "int")).columns) 953 # note that some numeric columns can be considered categorical 954 categorical_columns = list(self.map.select_dtypes( 955 include = ("category", "int")).columns) 956 try: 957 if isinstance(self.ids_to_map, str): 958 # wrap a single string in a list - this would be an unusual request... 959 if self.ids_to_map in list(self.map.tile_id): 960 print("""You have only requested a single attribute to map. 961 That's fine, but perhaps not what you intended?""") 962 self.ids_to_map = [self.ids_to_map] 963 else: 964 raise KeyError( 965 """You have requested a single non-existent attribute to map!""") 966 elif self.ids_to_map is None or not isinstance(self.ids_to_map, Iterable): 967 # default to using all of them in order 968 print("""No tile ids provided: setting all of them!""") 969 self.ids_to_map = sorted(list(set(self.map.tile_id))) 970 971 if self.vars_to_map is None or not isinstance(self.vars_to_map, Iterable): 972 self.vars_to_map = [] 973 if len(numeric_columns) == 0: 974 # if there are none then we can't do it 975 raise IndexError("""Attempting to set default variables, but 976 there are no numeric columns in the data!""") 977 if len(numeric_columns) < len(self.ids_to_map): 978 # if there are fewer available than we need then repeat some 979 print("""Fewer numeric columns in the data than elements in the 980 tile unit. Reusing as many as needed to make up the numbers""") 981 reps = len(self.ids_to_map) // len(numeric_columns) + 1 982 self.vars_to_map = (numeric_columns * reps)[:len(self.ids_to_map)] 983 elif len(numeric_columns) > len(self.ids_to_map): 984 # if there are more than we need let the user know, but trim list 985 print("""Note that you have supplied more variables to map than 986 there are distinct elements in the tile unit. Ignoring the 987 extras.""") 988 self.vars_to_map = numeric_columns[:len(self.ids_to_map)] 989 else: 990 self.vars_to_map = numeric_columns 991 # print(f"{self.vars_to_map=}") 992 993 if self.categoricals is None or not isinstance(self.categoricals, Iterable): 994 # provide a set of defaults 995 self.categoricals = [col not in numeric_columns for col in self.vars_to_map] 996 # print(f"{self.categoricals=}") 997 998 if isinstance(self.schemes_to_use, str): 999 self.schemes_to_use = [self.schemes_to_use] * len(self.ids_to_map) 1000 elif self.schemes_to_use is None or not isinstance(self.schemes_to_use, Iterable): 1001 # provide a set of defaults 1002 self.schemes_to_use = [None if cat else "EqualInterval" 1003 for cat in self.categoricals] 1004 # print(f"{self.schemes_to_use=}") 1005 1006 if isinstance(self.colors_to_use, str): 1007 self.colors_to_use = [self.colors_to_use] * len(self.ids_to_map) 1008 elif self.colors_to_use is None or not isinstance(self.colors_to_use, Iterable): 1009 # provide starter defaults 1010 print(f"""No colour maps provided! Setting some defaults.""") 1011 self.colors_to_use = ["Set1" if cat else "Reds" for cat in self.categoricals] 1012 for i, (col, cat) in enumerate(zip(self.colors_to_use, self.categoricals)): 1013 if cat and col not in CMAPS_CATEGORICAL: 1014 self.colors_to_use[i] = CMAPS_CATEGORICAL[i] 1015 # we'll allow diverging schemes for now... 1016 elif not cat and col not in CMAPS_SEQUENTIAL and col not in CMAPS_DIVERGING: 1017 self.colors_to_use[i] = CMAPS_SEQUENTIAL[i] 1018 # print(f"{self.colors_to_use=}") 1019 1020 if isinstance(self.n_classes, int): 1021 if self.n_classes == 0: 1022 self.n_classes = [255] * len(self.ids_to_map) 1023 else: 1024 self.n_classes = [self.n_classes] * len(self.ids_to_map) 1025 elif self.n_classes is None or not isinstance(self.n_classes, Iterable): 1026 # provide a set of defaults 1027 self.n_classes = [None if cat else 100 for cat in self.categoricals] 1028 # print(f"{self.n_classes=}") 1029 1030 except IndexError as e: 1031 e.add_note("""One or more of the supplied lists of mapping settings is 1032 an inappropriate length""") 1033 raise 1034 1035 self._colourspecs = { 1036 ID: {"column": v, 1037 "cmap": c, 1038 "categorical": cat, 1039 "scheme": s, 1040 "k": k} 1041 for ID, v, c, cat, s, k 1042 in zip(self.ids_to_map, 1043 self.vars_to_map, 1044 self.colors_to_use, 1045 self.categoricals, 1046 self.schemes_to_use, 1047 self.n_classes, strict = False)}
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 new values on
subsequent calls to TiledMap.render()
.
The most important options are the vars_map
and colors_to_use
settings.
vars_to_map
is a lost of the dataset variable names to match with
weavingspace.tileable.Tileable
elements with corresponding (ordered)
tile_ids (usually "a", "b", etc.). If you need to control the match, then
also supply ids_to_map
in matching order. E.g.
tm.ids_to_map = ['d', 'c', 'b', 'a']
tm.vars_to_map = ['x1', 'x2', 'x3', 'x4']
Note that this means that if you really want more than one element in the tiling to represent the same variable more than once, you can do that.
colors_to_use
is a parallel list of named matplotlib colormaps,
tm.colors_to_use = ["Reds", "Blues", "Greys", "Purples"]
Similarly, you can specify the classification schemes_to_use
(such as
'quantiles') and the number of classes n_classes
in each.
If data are categorical, this is flagged in the categoricals
list of
booleans, in which case an appropriate colour map should be used. There is
currently no provision for control of which colour in a categorical
colour map is applied to which variable level.
TODO: better control of categorical mapping schemes.
list specifying if each variable is -- or is to be treated as -- categorical
if True use radial key even for ordinal/ratio data (normally these will be shown by concentric tile geometries)
618 def render( 619 self, 620 **kwargs, 621 ) -> Figure: 622 """Render the current state to a map. 623 624 Note that TiledMap objects will usually be created by calling 625 `Tiling.get_tiled_map()`. 626 627 Args: 628 ids_to_map (list[str]): tile_ids to be used in the map. Defaults to None. 629 vars_to_map (list[str]): dataset columns to be mapped. Defaults to None. 630 colors_to_use (list[str]): list of matplotlib colormaps to be used. 631 Defaults to None. 632 categoricals (list[bool]): list of flags indicating if associated variable 633 should be treated as categorical. Defaults to None. 634 schemes_to_use (list[str]): list of strings indicating the mapclassify 635 scheme to use e.g. 'equalinterval' or 'quantiles'. Defaults to None. 636 n_classes (list[int]): list of ints indicating number of classes to use in 637 map classification. Defaults to None. 638 legend (bool): If True a legend will be drawn. Defaults to True. 639 legend_zoom (float): Zoom factor to apply to the legend. Values <1 640 will show more of the tile context. Defaults to 1.0. 641 legend_dx (float): x shift to apply to the legend position in plot area 642 relative units, i.e. 1.0 is full width of plot. Defaults to 0.0. 643 legend_dy (float): x and y shift to apply to the legend position in plot 644 area relative units, i.e. 1.0 is full height of plot. Defaults to 0.0. 645 use_ellipse (bool): If True applies an elliptical clip to the legend. 646 Defaults to False. 647 ellipse_magnification (float): Magnification to apply to ellipse clipped 648 legend. Defaults to 1.0. 649 radial_key (bool): If True legend key for TileUnit maps will be based on 650 radially dissecting the tiles, i.e. pie slices. Defaults to False. 651 draft_mode (bool): If True a map of the tiled map coloured by tile_ids 652 (and with no legend) is returned. Defaults to False. 653 figsize (tuple[float,floar]): plot dimensions passed to geopandas. 654 plot. Defaults to (20, 15). 655 dpi (float): passed to pyplot.plot. Defaults to 72. 656 **kwargs: other settings to pass to pyplot/geopandas.plot. 657 658 Returns: 659 matplotlib.figure.Figure: figure on which map is plotted. 660 661 """ 662 plt.rcParams["pdf.fonttype"] = 42 663 plt.rcParams["pdf.use14corefonts"] = True 664 matplotlib.rcParams["pdf.fonttype"] = 42 665 666 to_remove = set() # keep track of kwargs we use to setup TiledMap 667 # kwargs with no corresponding class attribute will be discarded 668 # because we are using slots, we have to use setattr() here 669 for k, v in kwargs.items(): 670 if k in self.__slots__: 671 setattr(self, k, v) 672 to_remove.add(k) 673 # remove any them so we don't pass them on to pyplot and get errors 674 for k in to_remove: 675 del kwargs[k] 676 677 if self.draft_mode: 678 fig = plt.figure(figsize = self.figsize) 679 ax = fig.add_subplot(111) 680 self.map.plot(ax = ax, column = "tile_id", cmap = "tab20", **kwargs) 681 ax.set_axis_off() 682 return fig 683 684 if self.legend: 685 # this sizing stuff is rough and ready for now, possibly forever... 686 reg_w, reg_h, *_ = \ 687 tiling_utils.get_width_height_left_bottom(self.map.geometry) 688 tile_w, tile_h, *_ = \ 689 tiling_utils.get_width_height_left_bottom( 690 self.tiling.tileable._get_legend_tiles().rotate( 691 self.tiling.rotation, origin = (0, 0))) 692 sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3 693 gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h], 694 "width_ratios": [reg_w, sf_w * tile_w]} 695 696 fig, axes = plt.subplot_mosaic([["map", "legend"], ["map", "."]], 697 gridspec_kw = gskw, 698 figsize = self.figsize, 699 layout = "constrained", **kwargs) 700 else: 701 fig, axes = plt.subplots(1, 1, figsize = self.figsize, 702 layout = "constrained", **kwargs) 703 704 self._plot_map(axes, **kwargs) 705 return fig
Render the current state to a map.
Note that TiledMap objects will usually be created by calling
Tiling.get_tiled_map()
.
Args: ids_to_map (list[str]): tile_ids to be used in the map. Defaults to None. vars_to_map (list[str]): dataset columns to be mapped. Defaults to None. colors_to_use (list[str]): list of matplotlib colormaps to be used. Defaults to None. categoricals (list[bool]): list of flags indicating if associated variable should be treated as categorical. Defaults to None. schemes_to_use (list[str]): list of strings indicating the mapclassify scheme to use e.g. 'equalinterval' or 'quantiles'. Defaults to None. n_classes (list[int]): list of ints indicating number of classes to use in map classification. 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 in plot area relative units, i.e. 1.0 is full width of plot. Defaults to 0.0. legend_dy (float): x and y shift to apply to the legend position in plot area relative units, i.e. 1.0 is full height of plot. 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, i.e. pie slices. 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. 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.
782 def to_file(self, fname:str) -> None: 783 """Output the tiled map to a layered GPKG file. 784 785 Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`. 786 787 Args: 788 fname (str): Filename to write. Defaults to None. 789 790 """ 791 tiling_utils.write_map_to_layers(self.map, fname)
Output the tiled map to a layered GPKG file.
Currently delegates to weavingspace.tiling_utils.write_map_to_layers()
.
Args: fname (str): Filename to write. Defaults to None.
794 def plot_legend(self, ax, **kwargs) -> None: 795 """Plot a legend for this tiled map. 796 797 Args: 798 ax (pyplot.Axes, optional): axes to draw legend. Defaults to None. 799 800 """ 801 ax.set_axis_off() 802 legend_tiles = self.tiling.tileable._get_legend_tiles() 803 # this is a bit hacky, but we will apply the rotation to text 804 # annotation so for TileUnits which don't need it, reverse that now 805 if isinstance(self.tiling.tileable, TileUnit): 806 # note that this confuses type hinting because pandas silently assigns 807 # a scalar value to a Series 808 legend_tiles.rotation = -self.tiling.rotation 809 810 legend_key = self._get_legend_key_gdf(legend_tiles) 811 legend_tiles.geometry = legend_tiles.geometry.rotate( 812 self.tiling.rotation, origin = (0, 0)) 813 814 if self.use_ellipse: 815 ellipse = tiling_utils.get_bounding_ellipse( 816 legend_tiles.geometry, mag = self.ellipse_magnification) 817 bb = ellipse.total_bounds 818 c = ellipse.union_all().centroid 819 else: 820 bb = legend_tiles.geometry.total_bounds 821 c = legend_tiles.geometry.union_all().centroid 822 823 # apply legend zoom - NOTE that this must be applied even 824 # if self.legend_zoom is == 1... 825 ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom, 826 c.x + (bb[2] - c.x) / self.legend_zoom) 827 ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom, 828 c.y + (bb[3] - c.y) / self.legend_zoom) 829 830 for cs, tile, rotn in zip(self._colourspecs.values(), 831 legend_tiles.geometry, 832 legend_tiles.rotation): 833 c = tile.centroid 834 ax.annotate(cs["column"], xy = (c.x, c.y), 835 ha = "center", va = "center", 836 rotation_mode = "anchor", 837 # adjust rotation to favour text reading left to right 838 rotation = (rotn + self.tiling.rotation + 90) % 180 - 90, 839 bbox = {"lw": 0, "fc": "#ffffff60"}) 840 841 # now plot background; we include the central tiles, since in 842 # the weave case these may not match the legend tiles 843 context_tiles = self.tiling.tileable \ 844 .get_local_patch(r = 2, include_0 = True) \ 845 .geometry.rotate(self.tiling.rotation, origin = (0, 0)) 846 if self.use_ellipse: 847 context_tiles.clip(ellipse, keep_geom_type = False).plot( 848 ax = ax, fc = "#9F9F9F3F", lw = .35) 849 tiling_utils.get_tiling_edges(context_tiles.geometry).clip( 850 ellipse, keep_geom_type = True).plot( 851 ax = ax, ec = "#5F5F5F", lw = .35) 852 else: 853 context_tiles.plot(ax = ax, fc = "#9F9F9F3F", 854 ec = "#5F5F5F", lw = .35) 855 tiling_utils.get_tiling_edges(context_tiles.geometry).plot( 856 ax = ax, ec = "#5F5F5F", lw = .35) 857 858 # plot the legend key tiles (which include the data) 859 self._plot_subsetted_gdf(ax, legend_key, **kwargs)
Plot a legend for this tiled map.
Args: ax (pyplot.Axes, optional): axes to draw legend. Defaults to None.