weavingspace.tile_map

Classes for tiling maps. Tiling and TiledMap are exposed in the public API and respectively enable creation of a tiling and plotting of the tiling as a multivariate map.

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

Class that applies a Tileable object to a region to be mapped.

The result of the tiling procedure is stored in the tiles variable and covers a region sufficient that the tiling can be rotated to any desired angle.

Tiling( unit: weavingspace.tileable.Tileable, region: geopandas.geodataframe.GeoDataFrame, id_var=None, prototile_margin: float = 0, tiles_sf: float = 1, tiles_margin: float = 0, as_icons: bool = False)
187  def __init__(self, unit:Tileable, region:gpd.GeoDataFrame, id_var = None,
188         prototile_margin:float = 0, tiles_sf:float = 1,
189         tiles_margin:float = 0, as_icons:bool = False) -> None:
190    """Class to persist a tiling by filling an area relative to
191    a region sufficient to apply the tiling at any rotation.
192
193    The Tiling constructor allows a number of adjustments to the supplied
194    `weavingspace.tileable.Tileable` object:
195
196    + `prototile_margin` values greater than 0 will introduce spacing of
197    the specified distance between tiles on the boundary of each tile
198    by applying the `TileUnit.inset_prototile()` method. Note that this
199    operation does not make sense for `WeaveUnit` objects,
200    and may not preserve the equality of tile areas.
201    + `tiles_sf` values less than one scale down tiles by applying the 
202    `TileUnit.scale_tiles()` method. Does not make sense for `WeaveUnit` 
203    objects.
204    + `tiles_margin` values greater than one apply a negative buffer of
205    the specified distance to every tile in the tiling by applying the
206    `Tileable.inset_tiles()` method. This option is applicable to both
207    `WeaveUnit` and `TileUnit` objects.
208
209    Args:
210      unit (Tileable): the tile_unit to use.
211      region (gpd.GeoDataFrame): the region to be tiled.
212      prototile_margin (float, optional): values greater than 0 apply an
213        inset margin to the tile unit. Defaults to 0.
214      tiles_sf (float, optional): scales the tiles. Defaults to 1.
215      tiles_margin (float, optional): applies a negative buffer to
216        the tiles. Defaults to 0.
217      as_icons (bool, optional): if True prototiles will only be placed at
218        the region's zone centroids, one per zone. Defaults to
219        False.
220    """
221    self.tile_unit = unit
222    self.rotation = self.tile_unit.rotation
223    if tiles_margin > 0:
224      self.tile_unit = self.tile_unit.inset_tiles(tiles_margin)
225    if tiles_sf != 1:
226      if isinstance(self.tile_unit, TileUnit):
227        self.tile_unit = self.tile_unit.scale_tiles(tiles_sf)
228      else:
229        print(f"""Applying scaling to tiles of a WeaveUnit does not make sense. 
230              Ignoring tiles_sf setting of {tiles_sf}.""")
231    if prototile_margin > 0:
232      if isinstance(self.tile_unit, TileUnit):
233        self.tile_unit = self.tile_unit.inset_prototile(prototile_margin)
234      else:
235        print(f"""Applying a prototile margin to a WeaveUnit does 
236              not make sense. Ignoring prototile_margin setting of
237              {prototile_margin}.""")
238    self.region = region
239    self.region.sindex
240    if id_var != None:
241      print("""id_var is no longer required and will be deprecated soon.
242            A temporary unique index attribute is added and removed when 
243            generating the tiled map.""")
244    if as_icons:
245      self.grid = _TileGrid(self.tile_unit, self.region.geometry, True)
246    else:
247      self.grid = _TileGrid(self.tile_unit, self.region.geometry)
248    self.tiles = self.make_tiling()
249    self.tiles.sindex

Class to persist a tiling by filling an area relative to a region sufficient to apply the tiling at any rotation.

The Tiling constructor allows a number of adjustments to the supplied weavingspace.tileable.Tileable object:

  • prototile_margin values greater than 0 will introduce spacing of the specified distance between tiles on the boundary of each tile by applying the TileUnit.inset_prototile() method. Note that this operation does not make sense for WeaveUnit objects, and may not preserve the equality of tile areas.
  • tiles_sf values less than one scale down tiles by applying the TileUnit.scale_tiles() method. Does not make sense for WeaveUnit objects.
  • tiles_margin values greater than one apply a negative buffer of the specified distance to every tile in the tiling by applying the Tileable.inset_tiles() method. This option is applicable to both WeaveUnit and TileUnit objects.
Arguments:
  • unit (Tileable): the tile_unit to use.
  • region (gpd.GeoDataFrame): the region to be tiled.
  • prototile_margin (float, optional): values greater than 0 apply an inset margin to the tile unit. Defaults to 0.
  • tiles_sf (float, optional): scales the tiles. Defaults to 1.
  • tiles_margin (float, optional): applies a negative buffer to the tiles. Defaults to 0.
  • as_icons (bool, optional): if True prototiles will only be placed at the region's zone centroids, one per zone. Defaults to False.
tile_unit: weavingspace.tileable.Tileable = None

tileable on which the tiling is based.

tile_shape: weavingspace.tileable.TileShape = None

base shape of the tileable.

region: geopandas.geodataframe.GeoDataFrame = None

the region to be tiled.

grid: weavingspace.tile_map._TileGrid = None

the grid which will be used to apply the tiling.

tiles: geopandas.geodataframe.GeoDataFrame = None

the tiles after tiling has been carried out.

rotation: float = 0.0

the cumulative rotation already applied to the tiling.

def get_tiled_map( self, rotation: float = 0.0, prioritise_tiles: bool = True, ragged_edges: bool = True, use_centroid_lookup_approximation=False, debug=False) -> TiledMap:
252  def get_tiled_map(self, rotation:float = 0.,
253            prioritise_tiles:bool = True,
254            ragged_edges:bool = True,
255            use_centroid_lookup_approximation = False,
256            debug = False) -> "TiledMap":
257    """Returns a `TiledMap` filling a region at the requested rotation.
258
259    HERE BE DRAGONS! This function took a lot of trial and error to get
260    right, so modify with CAUTION!
261
262    The `proritise_tiles = True` option means that the tiling will not
263    break up the tiles in `TileUnit`s at the boundaries between areas
264    in the mapped region, but will instead ensure that tiles remain
265    complete, picking up their data from the region zone which they overlap
266    the most.
267    
268    The exact order in which operations are performed affects performance.
269    For example, the final clipping to self.region when ragged_edges =
270    False is _much_ slower if it is carried out before the dissolving of
271    tiles into the region zones. So... again... modify CAREFULLY!
272
273    Args:
274      rotation (float, optional): An optional rotation to apply. Defaults
275        to 0.
276      prioritise_tiles (bool, optional): if True tiles will not be
277        broken at boundaries in the region dataset. Defaults to True.
278      ragged_edges (bool, optional): if True tiles at the edge of the
279        region will not be cut by the region extent - ignored if
280        prioritise_tiles is False when edges will always be clipped to
281        the region extent. Defaults to True.
282      use_centroid_lookup_approximation (bool, optional): if True use
283        tile centroids for lookup of region data - ignored if
284        prioritise_tiles is False when it is irrelevant. Defaults to
285        False.
286      debug (bool, optional): if True prints timing messages. Defaults
287        to False.
288
289    Returns:
290      TiledMap: a TiledMap of the source region.
291    """
292    if debug:
293      t1 = perf_counter()
294
295    id_var = self._setup_region_DZID()
296    tiled_map = self.rotated(rotation)
297    # compile a list of the variable names we are NOT going to change
298    # i.e. everything except the geometry and the id_var
299    region_vars = list(self.region.columns)
300    region_vars.remove("geometry")
301    region_vars.remove(id_var)
302
303    if debug:
304      t2 = perf_counter()
305      print(f"STEP 1: prep data (rotation if requested): {t2 - t1:.3f}")
306
307    if prioritise_tiles:  # maintain tile continuity across zone boundaries
308      # select only tiles inside a spacing buffer of the region
309      # make column with unique ID for every tile in the tiling
310      # the join ID is unique per tile
311      tiled_map["joinUID"] = list(range(tiled_map.shape[0]))
312
313      if use_centroid_lookup_approximation:
314        t5 = perf_counter()
315        tile_pts = copy.deepcopy(tiled_map)
316        tile_pts.geometry = tile_pts.centroid
317        lookup = tile_pts.sjoin(
318          self.region, how = "inner")[["joinUID", id_var]]
319      else:
320        # determine areas of overlapping tiles and drop the data we join the 
321        # data back later, so dropping makes that easier overlaying in region.
322        # overlay(tiles) seems to be faster??
323        # TODO: also... this part is performance-critical, think about fixes -- 
324        # possibly including the above centroid-based approx
325        overlaps = self.region.overlay(tiled_map, make_valid = False)
326        if debug:
327          t3 = perf_counter()
328          print(f"STEP A2: overlay zones with tiling: {t3 - t2:.3f}")
329        overlaps["area"] = overlaps.geometry.area
330        if debug:
331          t4 = perf_counter()
332          print(f"STEP A3: calculate areas: {t4 - t3:.3f}")
333        overlaps.drop(columns = region_vars, inplace = True)
334        if debug:
335          t5 = perf_counter()
336          print(f"STEP A4: drop columns prior to join: {t5 - t4:.3f}")
337        # make a lookup by largest area tile to region id
338        lookup = overlaps \
339          .iloc[overlaps.groupby("joinUID")["area"] \
340          .agg(pd.Series.idxmax)][["joinUID", id_var]]
341      # now join the lookup and from there the region data
342      if debug:
343        t6 = perf_counter()
344        print(f"STEP A5: build lookup for join: {t6 - t5:.3f}")
345      tiled_map = tiled_map \
346        .merge(lookup, on = "joinUID") \
347        .merge(self.region.drop(columns = ["geometry"]), on = id_var)
348      if debug:
349        t7 = perf_counter()
350        print(f"STEP A6: perform lookup join: {t7 - t6:.3f}")
351      tiled_map.drop(columns = ["joinUID"], inplace = True)
352
353    else:  # here we overlay
354      tiled_map = self.region.overlay(tiled_map)
355      t7 = perf_counter()
356      if debug:
357        print(f"STEP B2: overlay tiling with zones: {t7 - t2:.3f}")
358
359    tiled_map.drop(columns = [id_var], inplace = True)
360    self.region.drop(columns = [id_var], inplace = True)
361
362    # if we've retained tiles and want 'clean' edges, then clip
363    # note that this step is slow: geopandas unary_unions the clip layer
364    if prioritise_tiles and not ragged_edges:
365      tiled_map.sindex
366      tiled_map = tiled_map.clip(self.region)
367      if debug:
368        print(f"""STEP A7/B3: clip map to region: {perf_counter() - t7:.3f}""")
369
370    tm = TiledMap()
371    tm.tiling = self
372    tm.map = tiled_map
373    return tm

Returns a TiledMap filling a region at the requested rotation.

HERE BE DRAGONS! This function took a lot of trial and error to get right, so modify with CAUTION!

The proritise_tiles = True option means that the tiling will not break up the tiles in TileUnits at the boundaries between areas in the mapped region, but will instead ensure that tiles remain complete, picking up their data from the region zone which they overlap the most.

The exact order in which operations are performed affects performance. For example, the final clipping to self.region when ragged_edges = False is _much_ slower if it is carried out before the dissolving of tiles into the region zones. So... again... modify CAREFULLY!

Arguments:
  • rotation (float, optional): An optional rotation to apply. Defaults to 0.
  • prioritise_tiles (bool, optional): if True tiles will not be broken at boundaries in the region dataset. Defaults to True.
  • ragged_edges (bool, optional): if True tiles at the edge of the region will not be cut by the region extent - ignored if prioritise_tiles is False when edges will always be clipped to the region extent. Defaults to True.
  • use_centroid_lookup_approximation (bool, optional): if True use tile centroids for lookup of region data - ignored if prioritise_tiles is False when it is irrelevant. Defaults to False.
  • debug (bool, optional): if True prints timing messages. Defaults to False.
Returns:

TiledMap: a TiledMap of the source region.

def make_tiling(self) -> geopandas.geodataframe.GeoDataFrame:
421  def make_tiling(self) -> gpd.GeoDataFrame:
422    """Tiles the region with a tile unit, returning a GeoDataFrame
423
424    Returns:
425      geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the
426        tile unit.
427    """
428    # we assume the geometry column is called geometry so make it so...
429    if self.region.geometry.name != "geometry":
430      self.region.rename_geometry("geometry", inplace = True)
431
432    # chain list of lists of GeoSeries geometries to list of geometries
433    tiles = itertools.chain(*[
434      self.tile_unit.tiles.geometry.translate(p.x, p.y)
435      for p in self.grid.points])
436    # replicate the tile ids
437    ids = list(self.tile_unit.tiles.tile_id) * len(self.grid.points)
438    tile_ids = sorted(list(range(len(self.grid.points))) *
439              self.tile_unit.tiles.shape[0])
440    tiles_gs = gpd.GeoSeries(tiles)
441    # assemble and return as a GeoDataFrame
442    tiles_gdf = gpd.GeoDataFrame(
443      data = {"tile_id": ids, "prototile_id": tile_ids},
444      geometry = tiles_gs, crs = self.tile_unit.crs)
445    # unclear if we need the below or not...
446    return tiling_utils.gridify(tiles_gdf)

Tiles the region with a tile unit, returning a GeoDataFrame

Returns:

geopandas.GeoDataFrame: a GeoDataFrame of the region tiled with the tile unit.

def rotated(self, rotation: float = None) -> geopandas.geodataframe.GeoDataFrame:
449  def rotated(self, rotation:float = None) -> gpd.GeoDataFrame:
450    """Returns the stored tiling rotated.
451
452    Args:
453      rotation (float, optional): Rotation angle in degrees.
454        Defaults to None.
455
456    Returns:
457      gpd.GeoDataFrame: Rotated tiling.
458    """
459    if self.tiles is None:
460      self.tiles = self.make_tiling()
461    self.rotation = rotation
462    if self.rotation == 0:
463      return self.tiles
464    return gpd.GeoDataFrame(
465      data = {"tile_id": self.tiles.tile_id,
466          "prototile_id": self.tiles.tile_id},
467      crs = self.tiles.crs,
468      geometry = tiling_utils.gridify(
469        self.tiles.geometry.rotate(rotation, origin = self.grid.centre)))

Returns the stored tiling rotated.

Arguments:
  • rotation (float, optional): Rotation angle in degrees. Defaults to None.
Returns:

gpd.GeoDataFrame: Rotated tiling.

@dataclass
class TiledMap:
472@dataclass
473class TiledMap:
474  """Class representing a tiled map. Should not be accessed directly, but
475  will be created by calling `Tiling.get_tiled_map()`. After creation the
476  variables and colourmaps attributes can be set, and then
477  `TiledMap.render()` called to make a map. Settable attributes are explained
478  in documentation of the `TiledMap.render()` method.
479
480  Examples:
481    Recommended usage is as follows. First, make a `TiledMap` from a `Tiling` object.
482
483      tm = tiling.get_tiled_map(...)
484
485    Some options in the `Tiling` constructor affect the map appearance. See
486    `Tiling` for details.
487
488    Once a `TiledMap` object exists, set options on it, either when calling
489    `TiledMap.render()` or explicitly, i.e.
490
491      tm.render(opt1 = val1, opt2 = val2, ...)
492
493    or
494
495      tm.opt1 = val1
496      tm.opt2 = val2
497      tm.render()
498
499    Option settings are persistent, i.e. unless a new `TiledMap` object is
500    created the option settings have to be explicitly reset to default
501    values on subsequent calls to `TiledMap.render()`.
502
503    The most important options are the `variables` and `colourmaps`
504    settings.
505
506    `variables` is a dictionary mapping `weavingspace.tileable.Tileable`
507    tile_ids (usually "a", "b", etc.) to variable names in the data. For
508    example,
509
510      tm.variables = dict(zip(["a", "b"], ["population", "income"]))
511
512    `colourmaps` is a dictionary mapping dataset variable names to the
513    matplotlib colourmap to be used for each. For example,
514
515      tm.colourmaps = dict(zip(tm.variables.values(), ["Reds", "Blues"]))
516
517    See [this notebook](https://github.com/DOSull/weaving-space/blob/main/weavingspace/example-tiles-cairo.ipynb)
518    for simple usage.
519    TODO: This more complicated example shows how categorical maps can be
520    created.
521  """
522  # these will be set at instantion by Tiling.get_tiled_map()
523  tiling:Tiling = None
524  """the Tiling with the required tiles"""
525  map:gpd.GeoDataFrame = None
526  """the GeoDataFrame on which this map is based"""
527  variables:dict[str,str] = None 
528  """lookup from tile_id to variable names"""
529  colourmaps:dict[str,Union[str,dict]] = None
530  """lookup from variables to matplotlib cmaps"""
531
532  # the below parameters can be set either before calling self.render()
533  # or passed in as parameters to self.render()
534  # these are solely TiledMap.render() options
535  legend:bool = True
536  """whether or not to show a legend"""
537  legend_zoom:float = 1.0
538  """<1 zooms out from legend to show more context"""
539  legend_dx:float = 0.
540  """x shift of legend relative to the map"""
541  legend_dy:float = 0.
542  """y shift of legend relative to the map"""
543  use_ellipse:bool = False
544  """if True clips legend with an ellipse"""
545  ellipse_magnification:float = 1.0
546  """magnification to apply to clip ellipse"""
547  radial_key:bool = False
548  """if True use radial key even for ordinal/ratio data (normally these will be 
549  shown by concentric tile geometries)"""
550  draft_mode:bool = False
551  """if True plot the map coloured by tile_id"""
552
553  # the parameters below are geopandas.plot options which we intercept to
554  # ensure they are applied appropriately when we plot a GDF
555  scheme:str = "equalinterval"
556  """geopandas scheme to apply"""
557  k:int = 100
558  """geopandas number of classes to apply"""
559  figsize:tuple[float] = (20, 15)
560  """maptlotlib figsize"""
561  dpi:float = 72
562  """dpi for bitmap formats"""
563
564  def render(self, **kwargs) -> Figure:
565    """Renders the current state to a map.
566
567    Note that TiledMap objects will usually be created by calling
568    `Tiling.get_tiled_map()`.
569
570    Args:
571      variables (dict[str,str]): Mapping from tile_id values to
572        variable names. Defaults to None.
573      colourmaps (dict[str,Union[str,dict]]): Mapping from variable
574        names to colour map, either a colour palette as used by
575        geopandas/matplotlib, a fixed colour, or a dictionary mapping
576        categorical data values to colours. Defaults to None.
577      legend (bool): If True a legend will be drawn. Defaults to True.
578      legend_zoom (float): Zoom factor to apply to the legend. Values <1
579        will show more of the tile context. Defaults to 1.0.
580      legend_dx (float): x shift to apply to the legend position.
581        Defaults to 0.0.
582      legend_dy (float): x and y shift to apply to the legend position.
583        Defaults to 0.0.
584      use_ellipse (bool): If True applies an elliptical clip to the
585        legend. Defaults to False.
586      ellipse_magnification (float): Magnification to apply to ellipse
587        clipped legend. Defaults to 1.0.
588      radial_key (bool): If True legend key for TileUnit maps will be
589        based on radially dissecting the tiles. Defaults to False.
590      draft_mode (bool): If True a map of the tiled map coloured by
591        tile_ids (and with no legend) is returned. Defaults to False.
592      scheme (str): passed to geopandas.plot for numeric data. Defaults to
593        "equalinterval".
594      k (int): passed to geopandas.plot for numeric data. Defaults to 100.
595      figsize (tuple[float,floar]): plot dimensions passed to geopandas.
596        plot. Defaults to (20,15).
597      dpi (float): passed to pyplot.plot. Defaults to 72.
598      **kwargs: other settings to pass to pyplot/geopandas.plot.
599
600    Returns:
601      matplotlib.figure.Figure: figure on which map is plotted.
602    """
603    pyplot.rcParams['pdf.fonttype'] = 42
604    pyplot.rcParams['pdf.use14corefonts'] = True
605    matplotlib.rcParams['pdf.fonttype'] = 42
606
607    to_remove = set()  # keep track of kwargs we use to setup TiledMap
608    for k, v in kwargs.items():
609      if k in self.__dict__:
610        self.__dict__[k] = v
611        to_remove.add(k)
612    # remove them so we don't pass them on to pyplot and get errors
613    for k in to_remove:
614      del kwargs[k]
615
616    if self.draft_mode:
617      fig = pyplot.figure(figsize = self.figsize)
618      ax = fig.add_subplot(111)
619      self.map.plot(ax = ax, column = "tile_id", cmap = "tab20",
620              **kwargs)
621      return fig
622
623    if self.legend:
624      # this sizing stuff is rough and ready for now, possibly forever...
625      reg_w, reg_h, *_ = \
626        tiling_utils.get_width_height_left_bottom(self.map.geometry)
627      tile_w, tile_h, *_ = \
628        tiling_utils.get_width_height_left_bottom(
629          self.tiling.tile_unit._get_legend_tiles().rotate(
630            self.tiling.rotation, origin = (0, 0)))
631      sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3
632      gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h],
633              "width_ratios": [reg_w, sf_w * tile_w]}
634
635      fig, axes = pyplot.subplot_mosaic(
636        [["map", "legend"], ["map", "."]],
637        gridspec_kw = gskw, figsize = self.figsize,
638        layout = "constrained", **kwargs)
639    else:
640      fig, axes = pyplot.subplots(
641        1, 1, figsize = self.figsize,
642        layout = "constrained", **kwargs)
643
644    if self.variables is None:
645      # get any floating point columns available
646      default_columns = \
647        self.map.select_dtypes(
648          include = ("float64", "int64")).columns
649      self.variables = dict(zip(self.map.tile_id.unique(),
650                                list(default_columns)))
651      print(f"""No variables specified, picked the first
652            {len(self.variables)} numeric ones available.""")
653    elif isinstance(self.variables, (list, tuple)):
654      self.variables = dict(zip(
655        self.tiling.tile_unit.tiles.tile_id.unique(),
656        self.variables))
657      print(f"""Only a list of variables specified, assigning to
658            available tile_ids.""")
659
660    if self.colourmaps is None:
661      self.colourmaps = {}
662      for var in self.variables.values():
663        if self.map[var].dtype == pd.CategoricalDtype:
664          self.colourmaps[var] = "tab20"
665          print(f"""For categorical data, you should specify colour
666              mapping explicitly.""")
667        else:
668          self.colourmaps[var] = "Reds"
669
670    self._plot_map(axes, **kwargs)
671    return fig
672
673
674  def _plot_map(self, axes:pyplot.Axes, **kwargs) -> None:
675    """Plots map to the supplied axes.
676
677    Args:
678      axes (pyplot.Axes): axes on which maps will be drawn.
679    """
680    bb = self.map.geometry.total_bounds
681    if self.legend:
682      axes["map"].set_axis_off()
683      axes["map"].set_xlim(bb[0], bb[2])
684      axes["map"].set_ylim(bb[1], bb[3])
685      self._plot_subsetted_gdf(axes["map"], self.map, **kwargs)
686      self.plot_legend(ax = axes["legend"], **kwargs)
687      if (self.legend_dx != 0 or self.legend_dx != 0):
688        box = axes["legend"].get_position()
689        box.x0 = box.x0 + self.legend_dx
690        box.x1 = box.x1 + self.legend_dx
691        box.y0 = box.y0 + self.legend_dy
692        box.y1 = box.y1 + self.legend_dy
693        axes["legend"].set_position(box)
694    else:
695      axes.set_axis_off()
696      axes.set_xlim(bb[0], bb[2])
697      axes.set_ylim(bb[1], bb[3])
698      self._plot_subsetted_gdf(axes, self.map, **kwargs)
699    return None
700
701
702  def _plot_subsetted_gdf(self, ax:pyplot.Axes,
703                          gdf:gpd.GeoDataFrame, **kwargs) -> None:
704    """Plots a gpd.GeoDataFrame multiple times based on a subsetting
705    attribute (assumed to be "tile_id").
706
707    NOTE: used to plot both the main map _and_ the legend.
708
709    Args:
710      ax (pyplot.Axes): axes to plot to.
711      gdf (gpd.GeoDataFrame): the GeoDataFrame to plot.
712
713    Raises:
714      Exception: if self.colourmaps cannot be parsed exception is raised.
715    """
716    groups = gdf.groupby("tile_id")
717    for id, var in self.variables.items():
718      subset = groups.get_group(id)
719      # Handle custom color assignments via 'cmaps' parameter.
720      # Result is setting 'cmap' variable used in plot command afterwards.
721      if (isinstance(self.colourmaps[var], dict)):
722        colormap_dict = self.colourmaps[var]
723        data_unique_sorted = sorted(subset[var].unique())
724        cmap = matplotlib.colors.ListedColormap(
725          [colormap_dict[x] for x in data_unique_sorted])
726        subset.plot(ax = ax, column = var, cmap = cmap, **kwargs)
727      else:
728        if (isinstance(self.colourmaps,
729                (str, matplotlib.colors.Colormap,
730                matplotlib.colors.LinearSegmentedColormap,
731                matplotlib.colors.ListedColormap))):
732          cmap = self.colourmaps   # one palette for all ids
733        elif (len(self.colourmaps) == 0):
734          cmap = 'Reds'  # set a default... here, to Brewer's 'Reds'
735        elif (var not in self.colourmaps):
736          cmap = 'Reds'  # no color specified in dict, use default
737        elif (isinstance(self.colourmaps[var],
738                (str, matplotlib.colors.Colormap,
739                matplotlib.colors.LinearSegmentedColormap,
740                matplotlib.colors.ListedColormap))):
741          cmap = self.colourmaps[var]  # specified colors for this var
742        else:
743          raise Exception(f"""Color map for '{var}' is not a known
744                          type, but is {str(type(self.colourmaps[var]))}""")
745
746        subset.plot(ax = ax, column = var, cmap = cmap,
747              scheme = self.scheme, k = self.k, **kwargs)
748
749
750  def to_file(self, fname:str = None) -> None:
751    """Outputs the tiled map to a layered GPKG file.
752
753    Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`.
754
755    Args:
756      fname (str, optional): Filename to write. Defaults to None.
757    """
758    tiling_utils.write_map_to_layers(self.map, fname)
759    return None
760
761
762  def plot_legend(self, ax: pyplot.Axes = None, **kwargs) -> None:
763    """Plots a legend for this tiled map.
764
765    Args:
766      ax (pyplot.Axes, optional): axes to draw legend. Defaults to None.
767    """
768    # turn off axes (which seems also to make it impossible
769    # to set a background colour)
770    ax.set_axis_off()
771
772    legend_tiles = self.tiling.tile_unit._get_legend_tiles()
773    # this is a bit hacky, but we will apply the rotation to text
774    # annotation so for TileUnits which don't need it, reverse that now
775    if isinstance(self.tiling.tile_unit, TileUnit):
776      legend_tiles.rotation = -self.tiling.rotation
777
778    legend_key = self._get_legend_key_gdf(legend_tiles)
779
780    legend_tiles.geometry = legend_tiles.geometry.rotate(
781      self.tiling.rotation, origin = (0, 0))
782
783    if self.use_ellipse:
784      ellipse = tiling_utils.get_bounding_ellipse(
785        legend_tiles.geometry, mag = self.ellipse_magnification)
786      bb = ellipse.total_bounds
787      c = ellipse.unary_union.centroid
788    else:
789      bb = legend_tiles.geometry.total_bounds
790      c = legend_tiles.geometry.unary_union.centroid
791
792    # apply legend zoom - NOTE that this must be applied even
793    # if self.legend_zoom is not == 1...
794    ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom,
795          c.x + (bb[2] - c.x) / self.legend_zoom)
796    ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom,
797          c.y + (bb[3] - c.y) / self.legend_zoom)
798
799    # plot the legend key tiles (which include the data)
800    self._plot_subsetted_gdf(ax, legend_key, lw = 0, **kwargs)
801
802    for id, tile, rotn in zip(self.variables.keys(),
803                              legend_tiles.geometry,
804                              legend_tiles.rotation):
805      c = tile.centroid
806      ax.annotate(self.variables[id], xy = (c.x, c.y),
807          ha = "center", va = "center", rotation_mode = "anchor",
808          # adjust rotation to favour text reading left to right
809          rotation = (rotn + self.tiling.rotation + 90) % 180 - 90,
810          bbox = {"lw": 0, "fc": "#ffffff40"})
811
812    # now plot background; we include the central tiles, since in
813    # the weave case these may not match the legend tiles
814    context_tiles = self.tiling.tile_unit.get_local_patch(r = 2,
815      include_0 = True).geometry.rotate(self.tiling.rotation, origin = (0, 0))
816    # for reasons escaping all reason... invalid polygons sometimes show up
817    # here I think because of the rotation /shrug... in any case, this
818    # sledgehammer should fix it
819    # context_tiles = gpd.GeoSeries([g.simplify(1e-6)
820    #                                for g in context_tiles.geometry],
821    #                 crs = self.tiling.tile_unit.crs)
822
823    if self.use_ellipse:
824      context_tiles.clip(ellipse, keep_geom_type = False).plot(
825        ax = ax, fc = "#9F9F9F3F", lw = 0.0)
826      tiling_utils.get_tiling_edges(context_tiles.geometry).clip(
827        ellipse, keep_geom_type = True).plot(ax = ax, ec = "#5F5F5F", lw = 1)
828    else:
829      context_tiles.plot(ax = ax, fc = "#9F9F9F3F", ec = "#5F5F5F", lw = 0.0)
830      tiling_utils.get_tiling_edges(context_tiles.geometry).plot(
831        ax = ax, ec = "#5F5F5F", lw = 1)
832
833
834  def _get_legend_key_gdf(self, tiles:gpd.GeoDataFrame) -> gpd.GeoDataFrame:
835    """Returns a GeoDataFrame of tiles dissected and with data assigned 
836    to the slice so a map of them can stand as a legend.
837
838    'Dissection' is handled differently by `WeaveUnit` and `TileUnit`
839    objects and delegated to either `WeaveUnit._get_legend_key_shapes()`
840    or `TileUnit._get_legend_key_shapes()`.
841
842    Args:
843      tiles (gpd.GeoDataFrame): the legend tiles.
844
845    Returns:
846      gpd.GeoDataFrame:  with tile_id, variables and rotation
847        attributes, and geometries of Tileable tiles sliced into a
848        colour ramp or set of nested tiles.
849    """
850    key_tiles = []   # set of tiles to form a colour key (e.g. a ramp)
851    ids = []         # tile_ids applied to the keys
852    unique_ids = []  # list of each tile_id used in order
853    vals = []        # the data assigned to the key tiles
854    rots = []        # rotation of each key tile
855    subsets = self.map.groupby("tile_id")
856    for (id, var), geom, rot in zip(self.variables.items(),
857                 tiles.geometry,
858                 tiles.rotation):
859      subset = subsets.get_group(id)
860      d = subset[var]
861      radial = False
862      # if the data are categorical then it's complicated...
863      if d.dtype == pd.CategoricalDtype:
864        radial = True and self.radial_key
865        # desired order of categorical variable is the
866        # color maps dictionary keys
867        cmap = self.colourmaps[var]
868        num_cats = len(cmap)
869        val_order = dict(zip(cmap.keys(), range(num_cats)))
870        # compile counts of each category
871        freqs = [0] * num_cats
872        for v in list(d):
873          freqs[val_order[v]] += 1
874        # make list of the categories containing appropriate
875        # counts of each in the order needed using a reverse lookup
876        data_vals = list(val_order.keys())
877        data_vals = [data_vals[i] for i, f in enumerate(freqs) if f > 0]
878      else: # any other data is easy!
879        data_vals = sorted(d)
880        freqs = [1] * len(data_vals)
881      key = self.tiling.tile_unit._get_legend_key_shapes(
882        geom, freqs, rot, radial)
883      key_tiles.extend(key)
884      vals.extend(data_vals)
885      n = len(data_vals)
886      ids.extend([id] * n)
887      unique_ids.append(id)
888      rots.extend([rot] * n)
889    # finally make up a data table with all the data in all the
890    # columns (each set of data only gets used in the subset it
891    # applies to). This allows us to reuse the tiling_utils.
892    # plot_subsetted_gdf() function
893    key_data = {}
894    for id in unique_ids:
895      key_data[self.variables[id]] = vals
896
897    key_gdf = gpd.GeoDataFrame(
898      data = key_data | {"tile_id": ids, "rotation": rots},
899      crs = self.map.crs,
900      geometry = gpd.GeoSeries(key_tiles))
901    key_gdf.geometry = key_gdf.rotate(self.tiling.rotation, origin = (0, 0))
902    return key_gdf
903
904
905  def explore(self) -> None:
906    """TODO: add wrapper to make tiled web map via geopandas.explore.
907    """
908    return None

Class representing a tiled map. Should not be accessed directly, but will be created by calling Tiling.get_tiled_map(). After creation the variables and colourmaps attributes can be set, and then TiledMap.render() called to make a map. Settable attributes are explained in documentation of the TiledMap.render() method.

Examples:

Recommended usage is as follows. First, make a TiledMap from a Tiling object.

tm = tiling.get_tiled_map(...)

Some options in the Tiling constructor affect the map appearance. See Tiling for details.

Once a TiledMap object exists, set options on it, either when calling TiledMap.render() or explicitly, i.e.

tm.render(opt1 = val1, opt2 = val2, ...)

or

tm.opt1 = val1 tm.opt2 = val2 tm.render()

Option settings are persistent, i.e. unless a new TiledMap object is created the option settings have to be explicitly reset to default values on subsequent calls to TiledMap.render().

The most important options are the variables and colourmaps settings.

variables is a dictionary mapping weavingspace.tileable.Tileable tile_ids (usually "a", "b", etc.) to variable names in the data. For example,

tm.variables = dict(zip(["a", "b"], ["population", "income"]))

colourmaps is a dictionary mapping dataset variable names to the matplotlib colourmap to be used for each. For example,

tm.colourmaps = dict(zip(tm.variables.values(), ["Reds", "Blues"]))

See this notebook for simple usage. TODO: This more complicated example shows how categorical maps can be created.

TiledMap( tiling: Tiling = None, map: geopandas.geodataframe.GeoDataFrame = None, variables: dict[str, str] = None, colourmaps: dict[str, typing.Union[str, dict]] = None, legend: bool = True, legend_zoom: float = 1.0, legend_dx: float = 0.0, legend_dy: float = 0.0, use_ellipse: bool = False, ellipse_magnification: float = 1.0, radial_key: bool = False, draft_mode: bool = False, scheme: str = 'equalinterval', k: int = 100, figsize: tuple[float] = (20, 15), dpi: float = 72)
tiling: Tiling = None

the Tiling with the required tiles

map: geopandas.geodataframe.GeoDataFrame = None

the GeoDataFrame on which this map is based

variables: dict[str, str] = None

lookup from tile_id to variable names

colourmaps: dict[str, typing.Union[str, dict]] = None

lookup from variables to matplotlib cmaps

legend: bool = True

whether or not to show a legend

legend_zoom: float = 1.0

<1 zooms out from legend to show more context

legend_dx: float = 0.0

x shift of legend relative to the map

legend_dy: float = 0.0

y shift of legend relative to the map

use_ellipse: bool = False

if True clips legend with an ellipse

ellipse_magnification: float = 1.0

magnification to apply to clip ellipse

radial_key: bool = False

if True use radial key even for ordinal/ratio data (normally these will be shown by concentric tile geometries)

draft_mode: bool = False

if True plot the map coloured by tile_id

scheme: str = 'equalinterval'

geopandas scheme to apply

k: int = 100

geopandas number of classes to apply

figsize: tuple[float] = (20, 15)

maptlotlib figsize

dpi: float = 72

dpi for bitmap formats

def render(self, **kwargs) -> matplotlib.figure.Figure:
564  def render(self, **kwargs) -> Figure:
565    """Renders the current state to a map.
566
567    Note that TiledMap objects will usually be created by calling
568    `Tiling.get_tiled_map()`.
569
570    Args:
571      variables (dict[str,str]): Mapping from tile_id values to
572        variable names. Defaults to None.
573      colourmaps (dict[str,Union[str,dict]]): Mapping from variable
574        names to colour map, either a colour palette as used by
575        geopandas/matplotlib, a fixed colour, or a dictionary mapping
576        categorical data values to colours. Defaults to None.
577      legend (bool): If True a legend will be drawn. Defaults to True.
578      legend_zoom (float): Zoom factor to apply to the legend. Values <1
579        will show more of the tile context. Defaults to 1.0.
580      legend_dx (float): x shift to apply to the legend position.
581        Defaults to 0.0.
582      legend_dy (float): x and y shift to apply to the legend position.
583        Defaults to 0.0.
584      use_ellipse (bool): If True applies an elliptical clip to the
585        legend. Defaults to False.
586      ellipse_magnification (float): Magnification to apply to ellipse
587        clipped legend. Defaults to 1.0.
588      radial_key (bool): If True legend key for TileUnit maps will be
589        based on radially dissecting the tiles. Defaults to False.
590      draft_mode (bool): If True a map of the tiled map coloured by
591        tile_ids (and with no legend) is returned. Defaults to False.
592      scheme (str): passed to geopandas.plot for numeric data. Defaults to
593        "equalinterval".
594      k (int): passed to geopandas.plot for numeric data. Defaults to 100.
595      figsize (tuple[float,floar]): plot dimensions passed to geopandas.
596        plot. Defaults to (20,15).
597      dpi (float): passed to pyplot.plot. Defaults to 72.
598      **kwargs: other settings to pass to pyplot/geopandas.plot.
599
600    Returns:
601      matplotlib.figure.Figure: figure on which map is plotted.
602    """
603    pyplot.rcParams['pdf.fonttype'] = 42
604    pyplot.rcParams['pdf.use14corefonts'] = True
605    matplotlib.rcParams['pdf.fonttype'] = 42
606
607    to_remove = set()  # keep track of kwargs we use to setup TiledMap
608    for k, v in kwargs.items():
609      if k in self.__dict__:
610        self.__dict__[k] = v
611        to_remove.add(k)
612    # remove them so we don't pass them on to pyplot and get errors
613    for k in to_remove:
614      del kwargs[k]
615
616    if self.draft_mode:
617      fig = pyplot.figure(figsize = self.figsize)
618      ax = fig.add_subplot(111)
619      self.map.plot(ax = ax, column = "tile_id", cmap = "tab20",
620              **kwargs)
621      return fig
622
623    if self.legend:
624      # this sizing stuff is rough and ready for now, possibly forever...
625      reg_w, reg_h, *_ = \
626        tiling_utils.get_width_height_left_bottom(self.map.geometry)
627      tile_w, tile_h, *_ = \
628        tiling_utils.get_width_height_left_bottom(
629          self.tiling.tile_unit._get_legend_tiles().rotate(
630            self.tiling.rotation, origin = (0, 0)))
631      sf_w, sf_h = reg_w / tile_w / 3, reg_h / tile_h / 3
632      gskw = {"height_ratios": [sf_h * tile_h, reg_h - sf_h * tile_h],
633              "width_ratios": [reg_w, sf_w * tile_w]}
634
635      fig, axes = pyplot.subplot_mosaic(
636        [["map", "legend"], ["map", "."]],
637        gridspec_kw = gskw, figsize = self.figsize,
638        layout = "constrained", **kwargs)
639    else:
640      fig, axes = pyplot.subplots(
641        1, 1, figsize = self.figsize,
642        layout = "constrained", **kwargs)
643
644    if self.variables is None:
645      # get any floating point columns available
646      default_columns = \
647        self.map.select_dtypes(
648          include = ("float64", "int64")).columns
649      self.variables = dict(zip(self.map.tile_id.unique(),
650                                list(default_columns)))
651      print(f"""No variables specified, picked the first
652            {len(self.variables)} numeric ones available.""")
653    elif isinstance(self.variables, (list, tuple)):
654      self.variables = dict(zip(
655        self.tiling.tile_unit.tiles.tile_id.unique(),
656        self.variables))
657      print(f"""Only a list of variables specified, assigning to
658            available tile_ids.""")
659
660    if self.colourmaps is None:
661      self.colourmaps = {}
662      for var in self.variables.values():
663        if self.map[var].dtype == pd.CategoricalDtype:
664          self.colourmaps[var] = "tab20"
665          print(f"""For categorical data, you should specify colour
666              mapping explicitly.""")
667        else:
668          self.colourmaps[var] = "Reds"
669
670    self._plot_map(axes, **kwargs)
671    return fig

Renders the current state to a map.

Note that TiledMap objects will usually be created by calling Tiling.get_tiled_map().

Arguments:
  • variables (dict[str,str]): Mapping from tile_id values to variable names. Defaults to None.
  • colourmaps (dict[str,Union[str,dict]]): Mapping from variable names to colour map, either a colour palette as used by geopandas/matplotlib, a fixed colour, or a dictionary mapping categorical data values to colours. Defaults to None.
  • legend (bool): If True a legend will be drawn. Defaults to True.
  • legend_zoom (float): Zoom factor to apply to the legend. Values <1 will show more of the tile context. Defaults to 1.0.
  • legend_dx (float): x shift to apply to the legend position. Defaults to 0.0.
  • legend_dy (float): x and y shift to apply to the legend position. Defaults to 0.0.
  • use_ellipse (bool): If True applies an elliptical clip to the legend. Defaults to False.
  • ellipse_magnification (float): Magnification to apply to ellipse clipped legend. Defaults to 1.0.
  • radial_key (bool): If True legend key for TileUnit maps will be based on radially dissecting the tiles. Defaults to False.
  • draft_mode (bool): If True a map of the tiled map coloured by tile_ids (and with no legend) is returned. Defaults to False.
  • scheme (str): passed to geopandas.plot for numeric data. Defaults to "equalinterval".
  • k (int): passed to geopandas.plot for numeric data. Defaults to 100.
  • figsize (tuple[float,floar]): plot dimensions passed to geopandas. plot. Defaults to (20,15).
  • dpi (float): passed to pyplot.plot. Defaults to 72.
  • **kwargs: other settings to pass to pyplot/geopandas.plot.
Returns:

matplotlib.figure.Figure: figure on which map is plotted.

def to_file(self, fname: str = None) -> None:
750  def to_file(self, fname:str = None) -> None:
751    """Outputs the tiled map to a layered GPKG file.
752
753    Currently delegates to `weavingspace.tiling_utils.write_map_to_layers()`.
754
755    Args:
756      fname (str, optional): Filename to write. Defaults to None.
757    """
758    tiling_utils.write_map_to_layers(self.map, fname)
759    return None

Outputs the tiled map to a layered GPKG file.

Currently delegates to weavingspace.tiling_utils.write_map_to_layers().

Arguments:
  • fname (str, optional): Filename to write. Defaults to None.
def plot_legend(self, ax: matplotlib.axes._axes.Axes = None, **kwargs) -> None:
762  def plot_legend(self, ax: pyplot.Axes = None, **kwargs) -> None:
763    """Plots a legend for this tiled map.
764
765    Args:
766      ax (pyplot.Axes, optional): axes to draw legend. Defaults to None.
767    """
768    # turn off axes (which seems also to make it impossible
769    # to set a background colour)
770    ax.set_axis_off()
771
772    legend_tiles = self.tiling.tile_unit._get_legend_tiles()
773    # this is a bit hacky, but we will apply the rotation to text
774    # annotation so for TileUnits which don't need it, reverse that now
775    if isinstance(self.tiling.tile_unit, TileUnit):
776      legend_tiles.rotation = -self.tiling.rotation
777
778    legend_key = self._get_legend_key_gdf(legend_tiles)
779
780    legend_tiles.geometry = legend_tiles.geometry.rotate(
781      self.tiling.rotation, origin = (0, 0))
782
783    if self.use_ellipse:
784      ellipse = tiling_utils.get_bounding_ellipse(
785        legend_tiles.geometry, mag = self.ellipse_magnification)
786      bb = ellipse.total_bounds
787      c = ellipse.unary_union.centroid
788    else:
789      bb = legend_tiles.geometry.total_bounds
790      c = legend_tiles.geometry.unary_union.centroid
791
792    # apply legend zoom - NOTE that this must be applied even
793    # if self.legend_zoom is not == 1...
794    ax.set_xlim(c.x + (bb[0] - c.x) / self.legend_zoom,
795          c.x + (bb[2] - c.x) / self.legend_zoom)
796    ax.set_ylim(c.y + (bb[1] - c.y) / self.legend_zoom,
797          c.y + (bb[3] - c.y) / self.legend_zoom)
798
799    # plot the legend key tiles (which include the data)
800    self._plot_subsetted_gdf(ax, legend_key, lw = 0, **kwargs)
801
802    for id, tile, rotn in zip(self.variables.keys(),
803                              legend_tiles.geometry,
804                              legend_tiles.rotation):
805      c = tile.centroid
806      ax.annotate(self.variables[id], xy = (c.x, c.y),
807          ha = "center", va = "center", rotation_mode = "anchor",
808          # adjust rotation to favour text reading left to right
809          rotation = (rotn + self.tiling.rotation + 90) % 180 - 90,
810          bbox = {"lw": 0, "fc": "#ffffff40"})
811
812    # now plot background; we include the central tiles, since in
813    # the weave case these may not match the legend tiles
814    context_tiles = self.tiling.tile_unit.get_local_patch(r = 2,
815      include_0 = True).geometry.rotate(self.tiling.rotation, origin = (0, 0))
816    # for reasons escaping all reason... invalid polygons sometimes show up
817    # here I think because of the rotation /shrug... in any case, this
818    # sledgehammer should fix it
819    # context_tiles = gpd.GeoSeries([g.simplify(1e-6)
820    #                                for g in context_tiles.geometry],
821    #                 crs = self.tiling.tile_unit.crs)
822
823    if self.use_ellipse:
824      context_tiles.clip(ellipse, keep_geom_type = False).plot(
825        ax = ax, fc = "#9F9F9F3F", lw = 0.0)
826      tiling_utils.get_tiling_edges(context_tiles.geometry).clip(
827        ellipse, keep_geom_type = True).plot(ax = ax, ec = "#5F5F5F", lw = 1)
828    else:
829      context_tiles.plot(ax = ax, fc = "#9F9F9F3F", ec = "#5F5F5F", lw = 0.0)
830      tiling_utils.get_tiling_edges(context_tiles.geometry).plot(
831        ax = ax, ec = "#5F5F5F", lw = 1)

Plots a legend for this tiled map.

Arguments:
  • ax (pyplot.Axes, optional): axes to draw legend. Defaults to None.
def explore(self) -> None:
905  def explore(self) -> None:
906    """TODO: add wrapper to make tiled web map via geopandas.explore.
907    """
908    return None

TODO: add wrapper to make tiled web map via geopandas.explore.