matplotlib-map-utils 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ ############################################################
2
+ # north_arrow.py contains all the main objects and functions
3
+ # for creating the north arrow artist rendered to plots
4
+ ############################################################
5
+
6
+ ### IMPORTING PACKAGES ###
7
+
8
+ # Default packages
9
+ import warnings
10
+ import math
11
+ import copy
12
+ # Math packages
13
+ import numpy
14
+ # Geo packages
15
+ import cartopy
16
+ import pyproj
17
+ # Graphical packages
18
+ import matplotlib
19
+ import matplotlib.artist
20
+ import matplotlib.pyplot
21
+ import matplotlib.patches
22
+ import matplotlib.patheffects
23
+ import matplotlib.offsetbox
24
+ # matplotlib's useful validation functions
25
+ import matplotlib.rcsetup
26
+ # The types we use in this script
27
+ from typing import Literal
28
+ # The information contained in our helper scripts (validation and defaults)
29
+ import defaults
30
+ import validation
31
+
32
+ ### INITIALIZATION ###
33
+
34
+ # Setting the defaults to the "medium" size, which is roughly optimized for A4/Letter paper
35
+ # Making these as globals is important for the set_size() function to work later
36
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["md"]
37
+ _DEFAULT_ROTATION = defaults._ROTATION_ALL
38
+
39
+ ### CLASSES ###
40
+
41
+ # The main object model of the north arrow
42
+ # Note that, except for location, all the components for the artist are dictionaries
43
+ # These can be accessed and updated with dot notation (like NorthArrow.base)
44
+ class NorthArrow(matplotlib.artist.Artist):
45
+
46
+ ## INITIALIZATION ##
47
+ def __init__(self, location: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]="upper right",
48
+ scale: None | float | int=None,
49
+ base: None | bool | validation._TYPE_BASE = None, fancy: None | bool | validation._TYPE_FANCY = None,
50
+ label: None | bool | validation._TYPE_LABEL = None, shadow: None | bool | validation._TYPE_SHADOW = None,
51
+ pack: None | validation._TYPE_PACK = None, aob: None | validation._TYPE_AOB = None, rotation: None | validation._TYPE_ROTATION = None):
52
+ # Starting up the object with the base properties of a matplotlib Artist
53
+ matplotlib.artist.Artist.__init__(self)
54
+
55
+ # If a dictionary is passed to any of the elements, validate that it is "correct", and then store the information
56
+ # Note that we also merge the provided dict with the default style dict, so no keys are missing
57
+ # If a specific component is not desired, it should be set to False during initialization
58
+
59
+ # Location is stored as just a string
60
+ location = validation._validate(validation._VALIDATE_PRIMARY, "location", location)
61
+ self._location = location
62
+
63
+ # Scale will set to the default size if no value is passed
64
+ scale = validation._validate(validation._VALIDATE_PRIMARY, "scale", scale)
65
+ if scale is None:
66
+ self._scale = _DEFAULT_SCALE
67
+ else:
68
+ self._scale = scale
69
+
70
+ # Main elements
71
+ base = validation._validate_dict(base, _DEFAULT_BASE, validation._VALIDATE_BASE, return_clean=True, parse_false=False)
72
+ self._base = base
73
+
74
+ fancy = validation._validate_dict(fancy, _DEFAULT_FANCY, validation._VALIDATE_FANCY, return_clean=True, parse_false=False)
75
+ self._fancy = fancy
76
+
77
+ label = validation._validate_dict(label, _DEFAULT_LABEL, validation._VALIDATE_LABEL, return_clean=True, parse_false=False)
78
+ self._label = label
79
+
80
+ shadow = validation._validate_dict(shadow, _DEFAULT_SHADOW, validation._VALIDATE_SHADOW, return_clean=True, parse_false=False)
81
+ self._shadow = shadow
82
+
83
+ # Other properties
84
+ pack = validation._validate_dict(pack, _DEFAULT_PACK, validation._VALIDATE_PACK, return_clean=True, parse_false=False)
85
+ self._pack = pack
86
+ aob = validation._validate_dict(aob, _DEFAULT_AOB, validation._VALIDATE_AOB, return_clean=True, parse_false=False)
87
+ self._aob = aob
88
+ rotation = validation._validate_dict(rotation, _DEFAULT_ROTATION | rotation, validation._VALIDATE_ROTATION, return_clean=True, parse_false=False)
89
+ self._rotation = rotation
90
+
91
+ # We do set the zorder for our objects individually,
92
+ # but we ALSO set it for the entire artist, here
93
+ # Thank you to matplotlib-scalebar for this tip
94
+ zorder = 99
95
+
96
+ ## INTERNAL PROPERTIES ##
97
+ # This allows for easy-updating of properties
98
+ # Each property will have the same pair of functions
99
+ # 1) calling the property itself returns its dictionary (NorthArrow.base will output {...})
100
+ # 2) passing a dictionary will update key values (NorthArrow.base = {...} will update present keys)
101
+
102
+ # location/loc
103
+ @property
104
+ def location(self):
105
+ return self._location
106
+
107
+ @location.setter
108
+ def location(self, val: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]):
109
+ val = validation._validate(validation._VALIDATE_PRIMARY, "location", val)
110
+ self._location = val
111
+
112
+ @property
113
+ def loc(self):
114
+ return self._location
115
+
116
+ @loc.setter
117
+ def loc(self, val: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]):
118
+ val = validation._validate(validation._VALIDATE_PRIMARY, "location", val)
119
+ self._location = val
120
+
121
+ # scale
122
+ @property
123
+ def scale(self):
124
+ return self._scale
125
+
126
+ @scale.setter
127
+ def scale(self, val: None | float | int):
128
+ val = validation._validate(validation._VALIDATE_PRIMARY, "scale", val)
129
+ if val is None:
130
+ self._scale = _DEFAULT_SCALE
131
+ else:
132
+ self._scale = val
133
+
134
+ # base
135
+ @property
136
+ def base(self):
137
+ return self._base
138
+
139
+ @base.setter
140
+ def base(self, val: dict):
141
+ val = validation._validate_type("base", val, dict)
142
+ val = validation._validate_dict(val, self._base, validation._VALIDATE_BASE, return_clean=True, parse_false=False)
143
+ self._base = val
144
+
145
+ # fancy
146
+ @property
147
+ def fancy(self):
148
+ return self._fancy
149
+
150
+ @fancy.setter
151
+ def fancy(self, val: dict):
152
+ val = validation._validate_type("fancy", val, dict)
153
+ val = validation._validate_dict(val, self._fancy, validation._VALIDATE_FANCY, return_clean=True, parse_false=False)
154
+ self._fancy = val
155
+
156
+ # label
157
+ @property
158
+ def label(self):
159
+ return self._label
160
+
161
+ @label.setter
162
+ def label(self, val: dict):
163
+ val = validation._validate_type("label", val, dict)
164
+ val = validation._validate_dict(val, self._label, validation._VALIDATE_LABEL, return_clean=True, parse_false=False)
165
+ self._label = val
166
+
167
+ # shadow
168
+ @property
169
+ def shadow(self):
170
+ return self._shadow
171
+
172
+ @shadow.setter
173
+ def shadow(self, val: dict):
174
+ val = validation._validate_type("shadow", val, dict)
175
+ val = validation._validate_dict(val, self._shadow, validation._VALIDATE_SHADOW, return_clean=True, parse_false=False)
176
+ self._shadow = val
177
+
178
+ # pack
179
+ @property
180
+ def pack(self):
181
+ return self._pack
182
+
183
+ @pack.setter
184
+ def pack(self, val: dict):
185
+ val = validation._validate_type("pack", val, dict)
186
+ val = validation._validate_dict(val, self._pack, validation._VALIDATE_PACK, return_clean=True, parse_false=False)
187
+ self._pack = val
188
+
189
+ # aob
190
+ @property
191
+ def aob(self):
192
+ return self._aob
193
+
194
+ @aob.setter
195
+ def aob(self, val: dict):
196
+ val = validation._validate_type("aob", val, dict)
197
+ val = validation._validate_dict(val, self._aob, validation._VALIDATE_AOB, return_clean=True, parse_false=False)
198
+ self._aob = val
199
+
200
+ # rotation
201
+ @property
202
+ def rotation(self):
203
+ return self._rotation
204
+
205
+ @rotation.setter
206
+ def rotation(self, val: dict):
207
+ val = validation._validate_type("rotation", val, dict)
208
+ val = validation._validate_dict(val, self._rotation, validation._VALIDATE_ROTATION, return_clean=True, parse_false=False)
209
+ self._rotation = val
210
+
211
+ ## COPY FUNCTION ##
212
+ # This is solely to get around matplotlib's restrictions around re-using an artist across multiple axes
213
+ # Instead, you can use add_artist() like normal, but with add_artist(na.copy())
214
+ # Thank you to the cartopy team for helping fix a bug with this!
215
+ def copy(self):
216
+ return copy.deepcopy(self)
217
+
218
+ ## DRAW FUNCTION ##
219
+ # Calling ax.add_artist() on this object triggers the following draw() function
220
+ # THANK YOU to matplotlib-scalebar for figuring this out
221
+ # Note that we never specify the renderer - the axis takes care of it!
222
+ def draw(self, renderer, *args, **kwargs):
223
+ # Can re-use the drawing function we already established, but return the object instead
224
+ na_artist = north_arrow(ax=self.axes, location=self._location, scale=self._scale, draw=False,
225
+ base=self._base, fancy=self._fancy,
226
+ label=self._label, shadow=self._shadow,
227
+ pack=self._pack, aob=self._aob, rotation=self._rotation)
228
+ # This handles the actual drawing
229
+ na_artist.axes = self.axes
230
+ na_artist.set_figure(self.axes.get_figure())
231
+ na_artist.draw(renderer)
232
+
233
+ ## SIZE FUNCTION ##
234
+ # This function will update the default dictionaries used based on the size of map being created
235
+ # See defaults.py for more information on the dictionaries used here
236
+ def set_size(size: Literal["xs","xsmall","x-small",
237
+ "sm","small",
238
+ "md","medium",
239
+ "lg","large",
240
+ "xl","xlarge","x-large"]):
241
+ # Bringing in our global default values to update them
242
+ global _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB
243
+ # Changing the global default values as required
244
+ if size.lower() in ["xs","xsmall","x-small"]:
245
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["xs"]
246
+ elif size.lower() in ["sm","small"]:
247
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["sm"]
248
+ elif size.lower() in ["md","medium"]:
249
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["md"]
250
+ elif size.lower() in ["lg","large"]:
251
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["lg"]
252
+ elif size.lower() in ["xl","xlarge","x-large"]:
253
+ _DEFAULT_SCALE, _DEFAULT_BASE, _DEFAULT_FANCY, _DEFAULT_LABEL, _DEFAULT_SHADOW, _DEFAULT_PACK, _DEFAULT_AOB = defaults._DEFAULT_CONTAINER["xl"]
254
+ else:
255
+ raise ValueError("Invalid value supplied, try one of ['xsmall', 'small', 'medium', 'large', 'xlarge'] instead")
256
+
257
+ ### DRAWING FUNCTIONS ###
258
+
259
+ # This function presents a way to draw the north arrow independent of the NorthArrow object model
260
+ # and is actually used by the object model when draw() is called anyways
261
+ def north_arrow(ax, draw=True,
262
+ location: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]="upper right",
263
+ scale: None | float | int=None,
264
+ base: None | bool | validation._TYPE_BASE=None,
265
+ fancy: None | bool | validation._TYPE_FANCY=None,
266
+ label: None | bool | validation._TYPE_LABEL=None,
267
+ shadow: None | bool | validation._TYPE_SHADOW=None,
268
+ pack: None | validation._TYPE_PACK=None,
269
+ aob: None | validation._TYPE_AOB=None,
270
+ rotation: None | validation._TYPE_ROTATION=None):
271
+
272
+ # First, validating the two primary inputs
273
+ _location = validation._validate(validation._VALIDATE_PRIMARY, "location", location)
274
+
275
+ if scale is None:
276
+ _scale = _DEFAULT_SCALE
277
+ else:
278
+ _scale = validation._validate(validation._VALIDATE_PRIMARY, "scale", scale)
279
+
280
+ # This works the same as it does with the NorthArrow object
281
+ # If a dictionary is passed to any of the elements, first validate that it is "correct"
282
+ # Note that we also merge the provided dict with the default style dict, so no keys are missing
283
+ # If a specific component is not desired, it should be set to False in the function call
284
+ _base = validation._validate_dict(base, _DEFAULT_BASE, validation._VALIDATE_BASE, return_clean=True)
285
+ _fancy = validation._validate_dict(fancy, _DEFAULT_FANCY, validation._VALIDATE_FANCY, return_clean=True)
286
+ _label = validation._validate_dict(label, _DEFAULT_LABEL, validation._VALIDATE_LABEL, return_clean=True)
287
+ _shadow = validation._validate_dict(shadow, _DEFAULT_SHADOW, validation._VALIDATE_SHADOW, return_clean=True)
288
+ _pack = validation._validate_dict(pack, _DEFAULT_PACK, validation._VALIDATE_PACK, return_clean=True)
289
+ _aob = validation._validate_dict(aob, _DEFAULT_AOB, validation._VALIDATE_AOB, return_clean=True)
290
+ _rotation = validation._validate_dict(rotation, _DEFAULT_ROTATION, validation._VALIDATE_ROTATION, return_clean=True)
291
+
292
+ # First, getting the figure for our axes
293
+ fig = ax.get_figure()
294
+
295
+ # We will place the arrow components in an AuxTransformBox so they are scaled in inches
296
+ # Props to matplotlib-scalebar
297
+ scale_box = matplotlib.offsetbox.AuxTransformBox(fig.dpi_scale_trans)
298
+
299
+ ## BASE ARROW ##
300
+ # Because everything is dependent on this component, it ALWAYS exists
301
+ # However, if we don't want it (base=False), then we'll hide it
302
+ if base == False:
303
+ base_artist = matplotlib.patches.Polygon(_DEFAULT_BASE["coords"] * _scale, closed=True, visible=False, **_del_keys(_DEFAULT_BASE, ["coords","scale"]))
304
+ else:
305
+ base_artist = matplotlib.patches.Polygon(_base["coords"] * _scale, closed=True, visible=True, **_del_keys(_base, ["coords","scale"]))
306
+
307
+ ## ARROW SHADOW ##
308
+ # This is not its own artist, but instead just something we modify about the base artist using a path effect
309
+ if _shadow:
310
+ base_artist.set_path_effects([matplotlib.patheffects.withSimplePatchShadow(**_shadow)])
311
+
312
+ # With our base arrow "done", we can add it to scale_box
313
+ # which transforms our coordinates, multiplied by the scale factor, to inches
314
+ # so a line from (0,0) to (0,1) would be 1 inch long, and from (0,0) to (0,0.5) half an inch, etc.
315
+ scale_box.add_artist(base_artist)
316
+
317
+ ## FANCY ARROW ##
318
+ # If we want the fancy extra patch, we need another artist
319
+ if _fancy:
320
+ # Note that here, unfortunately, we are reliant on the scale attribute from the base arrow
321
+ fancy_artist = matplotlib.patches.Polygon(_fancy["coords"] * _scale, closed=True, visible=bool(_fancy), **_del_keys(_fancy, ["coords"]))
322
+ # It is also added to the scale_box so it is scaled in-line
323
+ scale_box.add_artist(fancy_artist)
324
+
325
+ ## LABEL ##
326
+ # The final artist is for the label
327
+ if _label:
328
+ # Correctly constructing the textprops dict for the label
329
+ text_props = _del_keys(_label, ["text","position","stroke_width","stroke_color"])
330
+ # If we have stroke settings, create a path effect for them
331
+ if _label["stroke_width"] > 0:
332
+ label_stroke = [matplotlib.patheffects.withStroke(linewidth=_label["stroke_width"], foreground=_label["stroke_color"])]
333
+ text_props["path_effects"] = label_stroke
334
+ # The label is not added to scale_box, it lives in its own TextArea artist instead
335
+ # Also, the dictionary does not need to be unpacked, textprops does that for us
336
+ label_box = matplotlib.offsetbox.TextArea(_label["text"], textprops=text_props)
337
+
338
+ ## STACKING THE ARTISTS ##
339
+ # If we have multiple artists, we need to stack them using a V or H packer
340
+ if _label and (_base or _fancy):
341
+ if _label["position"]=="top":
342
+ pack_box = matplotlib.offsetbox.VPacker(children=[label_box, scale_box], **_pack)
343
+ elif _label["position"]=="bottom":
344
+ pack_box = matplotlib.offsetbox.VPacker(children=[scale_box, label_box], **_pack)
345
+ elif _label["position"]=="left":
346
+ pack_box = matplotlib.offsetbox.HPacker(children=[label_box, scale_box], **_pack)
347
+ elif _label["position"]=="right":
348
+ pack_box = matplotlib.offsetbox.HPacker(children=[scale_box, label_box], **_pack)
349
+ else:
350
+ raise Exception("Invalid position applied, try one of 'top', 'bottom', 'left', 'right'")
351
+ # If we only have the base, then that's the only thing we'll add to the box
352
+ # I keep this in a VPacker just so that the rest of the code is functional, and doesn't depend on a million if statements
353
+ else:
354
+ pack_box = matplotlib.offsetbox.VPacker(children=[scale_box], **_pack)
355
+
356
+ ## CREATING THE OFFSET BOX ##
357
+ # The AnchoredOffsetBox allows us to place the pack_box relative to our axes
358
+ # Note that the position string (upper left, lower right, center, etc.) comes from the location variable
359
+ aob_box = matplotlib.offsetbox.AnchoredOffsetbox(loc=_location, child=pack_box, **_del_keys(_aob, ["facecolor","edgecolor","alpha"]))
360
+ # Also setting the facecolor and transparency of the box
361
+ if _aob["facecolor"] is not None:
362
+ aob_box.patch.set_facecolor(_aob["facecolor"])
363
+ aob_box.patch.set_visible(True)
364
+ if _aob["edgecolor"] is not None:
365
+ aob_box.patch.set_edgecolor(_aob["edgecolor"])
366
+ aob_box.patch.set_visible(True)
367
+ if _aob["alpha"]:
368
+ aob_box.patch.set_alpha(_aob["alpha"])
369
+ aob_box.patch.set_visible(True)
370
+
371
+ ## ROTATING THE ARROW ##
372
+ # If no rotation amount is passed, (attempt to) calculate it
373
+ if _rotation["degrees"] is None:
374
+ rotate_deg = _rotate_arrow(ax, _rotation)
375
+ else:
376
+ rotate_deg = _rotation["degrees"]
377
+ # Then, apply the rotation to the aob box
378
+ _iterative_rotate(aob_box, rotate_deg)
379
+
380
+ ## DRAWING ##
381
+ # If this option is set to true, we'll draw the final artists as desired
382
+ if draw==True:
383
+ ax.add_artist(aob_box)
384
+ # If not, we'll return the aob_box as an artist object (the NorthArrow draw() function uses this)
385
+ else:
386
+ return aob_box
387
+
388
+ ### HELPING FUNTIONS ###
389
+ # These are quick functions we use to help in other parts of this process
390
+
391
+ # This function calculates the desired rotation of the arrow
392
+ # It uses 3 pieces of information: the CRS, the reference frame, and the coordinates of the reference point
393
+ # This code is 100% inspired by EOMaps, who also answered my questions about the inner workings of their equiavlent functions
394
+ def _rotate_arrow(ax, rotate_dict) -> float | int:
395
+ crs = rotate_dict["crs"]
396
+ ref = rotate_dict["reference"]
397
+ crd = rotate_dict["coords"] # should be (x,y) for axis ref, (lat,lng) for data ref
398
+
399
+ ## CONVERTING FROM AXIS TO DATA COORDAINTES ##
400
+ # If reference is set to axis, need to convert the axis coordinates (ranging from 0 to 1) to data coordinates (in native crs)
401
+ if ref=="axis":
402
+ # the transLimits transformation is for converting between data and axes units
403
+ # so this code gets us the data (geographic) units of the chosen axis coordinates
404
+ reference_point = ax.transLimits.inverted().transform((crd[0], crd[1]))
405
+ # If reference is set to center, then do the same thing, but use the midpoint of the axis by default
406
+ elif ref=="center":
407
+ reference_point = ax.transLimits.inverted().transform((0.5,0.5))
408
+ # Finally if the reference is set to data, we assume the provided coordinates are already in data units, no transformation needed!
409
+ elif ref=="data":
410
+ reference_point = crd
411
+
412
+ ## CONVERTING TO GEODETIC COORDINATES ##
413
+ # Initializing a CRS, so we can transform between coordinate systems appropriately
414
+ if type(crs) == pyproj.CRS:
415
+ og_proj = cartopy.crs.CRS(crs)
416
+ else:
417
+ try:
418
+ og_proj = cartopy.crs.CRS(pyproj.CRS(crs))
419
+ except:
420
+ raise Exception("Invalid CRS Supplied")
421
+ # Converting to the geodetic version of the CRS supplied
422
+ gd_proj = og_proj.as_geodetic()
423
+ # Converting reference point to the geodetic system
424
+ reference_point_gd = gd_proj.transform_point(reference_point[0], reference_point[1], og_proj)
425
+ # Converting the coordinates BACK to the original system
426
+ reference_point = og_proj.transform_point(reference_point_gd[0], reference_point_gd[1], gd_proj)
427
+ # And adding an offset to find "north", relative to that
428
+ north_point = og_proj.transform_point(reference_point_gd[0], reference_point_gd[1] + 0.01, gd_proj)
429
+
430
+ ## CALCULATING THE ANGLE ##
431
+ # numpy.arctan2 wants coordinates in (y,x) because it flips them when doing the calculation
432
+ # i.e. the angle found is between the line segment ((0,0), (1,0)) and ((0,0), (b,a)) when calling numpy.arctan2(a,b)
433
+ try:
434
+ rad = -1 * numpy.arctan2(north_point[0] - reference_point[0], north_point[1] - reference_point[1])
435
+ except:
436
+ warnings.warn("Unable to calculate rotation of arrow, setting to 0 degrees")
437
+ rad = 0
438
+ # Converting radians to degrees
439
+ deg = math.degrees(rad)
440
+
441
+ # Returning the degree number
442
+ return deg
443
+
444
+ # Unfortunately, matplotlib doesn't allow AnchoredOffsetBoxes or V/HPackers to have a rotation transformation (why? No idea)
445
+ # So, we have to set it on the individual child objects (namely the base arrow and fancy arrow patches)
446
+ def _iterative_rotate(artist, deg):
447
+ # Building the affine rotation transformation
448
+ transform_rotation = matplotlib.transforms.Affine2D().rotate_deg(deg)
449
+ artist.set_transform(transform_rotation + artist.get_transform())
450
+ # Repeating the process if there is a child component
451
+ if artist.get_children():
452
+ for child in artist.get_children():
453
+ _iterative_rotate(child, deg)
454
+
455
+ # This function will remove any keys we specify from a dictionary
456
+ # This is useful if we need to unpack on certain values from a dictionary, and is used in north_arrow()
457
+ def _del_keys(dict, to_remove):
458
+ return {key: val for key, val in dict.items() if key not in to_remove}