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,1185 @@
1
+ # Default packages
2
+ import warnings
3
+ import math
4
+ import copy
5
+ # Math packages
6
+ import numpy
7
+ # Geo packages
8
+ import cartopy
9
+ import pyproj
10
+ # Graphical packages
11
+ import matplotlib
12
+ import matplotlib.artist
13
+ import matplotlib.pyplot
14
+ import matplotlib.patches
15
+ import matplotlib.patheffects
16
+ import matplotlib.offsetbox
17
+ # matplotlib's useful validation functions
18
+ import matplotlib.rcsetup
19
+ # Finally, the types we use in this script
20
+ from typing import Tuple, TypedDict, Literal, get_args
21
+
22
+ ### TYPE HINTS ###
23
+ # This section of the code is for defining structured dictionaries and lists
24
+ # for the custom data structures we've created (such as the style dictionaries)
25
+ # so that intellisense can help with autocompletion
26
+ class _TYPE_BASE(TypedDict, total=False):
27
+ coords: numpy.array # must be 2D numpy array
28
+ location: Literal["upper right", "upper left", "lower left", "lower right",
29
+ "center left", "center right", "lower center", "upper center"] # can be https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.legend.html (see: loc)
30
+ scale: float # between 0 and inf
31
+ facecolor: str # any color value for matplotlib
32
+ edgecolor: str # any color value for matplotlib
33
+ linewidth: float | int # between 0 and inf
34
+ zorder: int # any integer
35
+
36
+ class _TYPE_FANCY(TypedDict, total=False):
37
+ coords: numpy.array # must be 2D numpy array
38
+ facecolor: str # any color value for matplotlib
39
+ zorder: int # any integer
40
+
41
+ class _TYPE_LABEL(TypedDict, total=False):
42
+ text: str # any string that you want to display ("N" or "North" being the most common)
43
+ position: Literal["top", "bottom", "left", "right"] # from matplotlib documentation
44
+ ha: Literal["left", "center", "right"] # from matplotlib documentation
45
+ va: Literal["baseline", "bottom", "center", "center_baseline", "top"] # from matplotlib documentation
46
+ fontsize: str | float | int # any fontsize value for matplotlib
47
+ fontfamily: Literal["serif", "sans-serif", "cursive", "fantasy", "monospace"] # from matplotlib documentation
48
+ fontstyle: Literal["normal", "italic", "oblique"] # from matplotlib documentation
49
+ color: str # any color value for matplotlib
50
+ fontweight: Literal["normal", "bold", "heavy", "light", "ultrabold", "ultralight"] # from matplotlib documentation
51
+ stroke_width: float | int # between 0 and infinity
52
+ stroke_color: str # any color value for matplotlib
53
+ rotation: float | int # between -360 and 360
54
+ zorder: int # any integer
55
+
56
+ class _TYPE_SHADOW(TypedDict, total=False):
57
+ offset: Tuple[float | int, float | int] # two-length tuple or list of x,y values in points
58
+ alpha: float | int # between 0 and 1
59
+ shadow_rgbFace: str # any color vlaue for matplotlib
60
+
61
+ class _TYPE_PACK(TypedDict, total=False):
62
+ sep: float | int # between 0 and inf
63
+ align: Literal["top", "bottom", "left", "right", "center", "baseline"] # from matplotlib documentation
64
+ pad: float | int # between 0 and inf
65
+ width: float | int # between 0 and inf
66
+ height: float | int # between 0 and inf
67
+ mode: Literal["fixed", "expand", "equal"] # from matplotlib documentation
68
+
69
+ class _TYPE_AOB(TypedDict, total=False):
70
+ facecolor: str # NON-STANDARD: used to set the facecolor of the offset box (i.e. to white), any color vlaue for matplotlib
71
+ edgecolor: str # NON-STANDARD: used to set the edge of the offset box (i.e. to black), any color vlaue for matplotlib
72
+ alpha: float | int # NON-STANDARD: used to set the transparency of the face color of the offset box^, between 0 and 1
73
+ pad: float | int # between 0 and inf
74
+ borderpad: float | int # between 0 and inf
75
+ prop: str | float | int # any fontsize value for matplotlib
76
+ frameon: bool # any bool
77
+ # bbox_to_anchor: None
78
+ # bbox_transform: None
79
+
80
+ class _TYPE_ROTATION(TypedDict, total=False):
81
+ degrees: float | int # anything between -360 and 360, or None for "auto"
82
+ crs: str | int | pyproj.CRS # only required if degrees is None: should be a valid cartopy or pyproj crs, or a string that can be converted to that
83
+ reference: Literal["axis", "data", "center"] # only required if degrees is None: should be either "axis" or "data" or "center"
84
+ coords: Tuple[float | int, float | int] # only required if degrees is None: should be a tuple of coordinates in the relevant reference window
85
+
86
+ ### DEFAULT VALUES ###
87
+ # This section of the code is for storing default values
88
+ # that will be fed into the various classes upon creation
89
+
90
+ # Defaults for the base arrow class
91
+ _COORDS_BASE = numpy.array([
92
+ (0.50, 1.00),
93
+ (0.10, 0.00),
94
+ (0.50, 0.10),
95
+ (0.90, 0.00),
96
+ (0.50, 1.00)
97
+ ])
98
+
99
+ _DEFAULT_BASE = {
100
+ "coords":_COORDS_BASE,
101
+ "location":"upper right",
102
+ "scale":0.50,
103
+ "facecolor":"white",
104
+ "edgecolor":"black",
105
+ "linewidth":1,
106
+ "zorder":98
107
+ }
108
+
109
+ # Defaults for the "fancy" arrow, i.e. the patch overlaid on top
110
+ _COORDS_FANCY = numpy.array([
111
+ (0.50, 0.85),
112
+ (0.50, 0.20),
113
+ (0.80, 0.10),
114
+ (0.50, 0.85)
115
+ ])
116
+
117
+ _DEFAULT_FANCY = {
118
+ "coords":_COORDS_FANCY,
119
+ "facecolor":"black",
120
+ "zorder":99
121
+ }
122
+
123
+ # Defaults for the label of the arrow
124
+ _DEFAULT_LABEL = {
125
+ "text":"N",
126
+ "position":"top",
127
+ "ha":"center",
128
+ "va":"baseline",
129
+ "fontsize":"large",
130
+ "fontfamily":"sans-serif",
131
+ "fontstyle":"normal",
132
+ "color":"black",
133
+ "fontweight":"regular",
134
+ "stroke_width":1,
135
+ "stroke_color":"white",
136
+ "rotation":0,
137
+ "zorder":99
138
+ }
139
+
140
+ # Defaults for the shadow of the arrow
141
+ _DEFAULT_SHADOW = {
142
+ "offset":(4,-4),
143
+ "alpha":0.5,
144
+ "shadow_rgbFace":"black",
145
+ }
146
+
147
+ # Defaults for the VPacker or HPacker (see north_arrow for where it is used)
148
+ _DEFAULT_PACK = {
149
+ "sep":5,
150
+ "align":"center",
151
+ "pad":0,
152
+ "width":None,
153
+ "height":None,
154
+ "mode":"fixed"
155
+ }
156
+
157
+ # Defaults for the AnchoredOffsetBox (see north_arrow for where it is used)
158
+ _DEFAULT_AOB = {
159
+ "facecolor":None,
160
+ "edgecolor":None,
161
+ "alpha":None,
162
+ "pad":0.4,
163
+ "borderpad":0.5,
164
+ "prop":"medium",
165
+ "frameon":False,
166
+ "bbox_to_anchor":None,
167
+ "bbox_transform":None
168
+ }
169
+
170
+ # Defaults for rotating the arrow to point towards True North (see _rotate_arrow for how it is used)
171
+ _DEFAULT_ROTATION = {
172
+ "degrees":None,
173
+ "crs":None,
174
+ "reference":None,
175
+ "coords":None
176
+ }
177
+
178
+ ### CLASSES ###
179
+ # Defining the main object model of the north arrow
180
+ # Note that base, fancy, label, and shadow are actually all "child" classes
181
+ # and are accessible through their _ names (self._base, self._shadow, etc.)
182
+ class NorthArrow(matplotlib.artist.Artist):
183
+ # Initialization upon first creation
184
+ # TODO: MOVE LOCATION, RENDER, ROTATION, AND CRS IN THIS
185
+ def __init__(self, base: bool=True, base_style: _TYPE_BASE=_DEFAULT_BASE, fancy: bool=True, fancy_style: _TYPE_FANCY=_DEFAULT_FANCY, label: bool=True, label_style: _TYPE_LABEL=_DEFAULT_LABEL, shadow: bool=True, shadow_style: _TYPE_SHADOW=_DEFAULT_SHADOW, packer_style: _TYPE_PACK=_DEFAULT_PACK, aob_style: _TYPE_AOB=_DEFAULT_AOB, rotation_style: _TYPE_ROTATION=_DEFAULT_ROTATION):
186
+ # Starting up the artist object with the base properties
187
+ matplotlib.artist.Artist.__init__(self)
188
+ # If any of the child classes are set to True, initialize their class with the options we have
189
+ # Otherwise, set them as False (so we're aware)
190
+ # Note that we are setting the style FIRST, by merging the provided dict with the default style dict
191
+ # This allows us to not repeat that operation twice, and just use that in the init of the child class
192
+ # ArrowBase
193
+ # Using our handy-dandy function to validate
194
+ base_style = _validate_dict(_DEFAULT_BASE | base_style, _VALIDATE_BASE, return_clean=True)
195
+ self._base_style = base_style
196
+ if base==True:
197
+ self._base = ArrowBase(self, **self._base_style)
198
+ else:
199
+ self._base = base
200
+ # ArrowFancy
201
+ fancy_style = _validate_dict(_DEFAULT_FANCY | fancy_style, _VALIDATE_FANCY, return_clean=True)
202
+ self._fancy_style = fancy_style
203
+ if fancy==True:
204
+ self._fancy = ArrowFancy(self, **self._fancy_style)
205
+ else:
206
+ self._fancy = fancy
207
+ # ArrowLabel
208
+ label_style = _validate_dict(_DEFAULT_LABEL | label_style, _VALIDATE_LABEL, return_clean=True)
209
+ self._label_style = label_style
210
+ if label==True:
211
+ self._label = ArrowLabel(self, **self._label_style)
212
+ else:
213
+ self._label = label
214
+ # ArrowShadow
215
+ shadow_style = _validate_dict(_DEFAULT_SHADOW | shadow_style, _VALIDATE_SHADOW, return_clean=True)
216
+ self._shadow_style = shadow_style
217
+ if shadow==True:
218
+ self._shadow = ArrowShadow(self, **self._shadow_style)
219
+ else:
220
+ self._shadow = shadow
221
+ # Other style properties
222
+ packer_style = _validate_dict(_DEFAULT_PACK | packer_style, _VALIDATE_PACK, return_clean=True)
223
+ self._packer_style = packer_style
224
+ aob_style = _validate_dict(_DEFAULT_AOB | aob_style, _VALIDATE_AOB, return_clean=True)
225
+ self._aob_style = aob_style
226
+ rotation_style = _validate_dict(_DEFAULT_ROTATION | rotation_style, _VALIDATE_ROTATION, return_clean=True)
227
+ self._rotation_style = rotation_style
228
+
229
+ # We do set the zorder for our objects individually,
230
+ # but we ALSO set it for the entire artist, here, for some reason
231
+ # idk I just stole this from matplotlib-scalebar
232
+ zorder = 99
233
+
234
+ ## INTERNAL PROPERTIES ##
235
+ # This allows for easy-updating of class values
236
+
237
+ # TODO: If these values change, then the class needs to be updated (either destroyed or created)
238
+ # base
239
+ @property
240
+ def base(self):
241
+ return self._base
242
+
243
+ @base.setter
244
+ def base(self, val: bool):
245
+ val = _validate_type("base", val, bool)
246
+ self._base = _obj_setter(self, self._base, val, self._base_style)
247
+
248
+ # TODO: if these values change, and an object already exists, diff the dict
249
+ # I think you can get a current dict with https://stackoverflow.com/questions/61517/python-dictionary-from-an-objects-fields
250
+ # And you can diff the dict using the pipe operator {new} | {old}
251
+ # base_style
252
+ @property
253
+ def base_style(self):
254
+ return self._base_style
255
+
256
+ @base_style.setter
257
+ def base_style(self, val: dict):
258
+ val = _validate_type("base_style", val, dict)
259
+ val = _validate_dict(val, _VALIDATE_BASE, return_clean=True)
260
+ self._base_style = self._base_style | val
261
+ if self._base:
262
+ self._base = ArrowBase(self, **self._base_style)
263
+
264
+ # fancy
265
+ @property
266
+ def fancy(self):
267
+ return self._fancy
268
+
269
+ @fancy.setter
270
+ def fancy(self, val: bool):
271
+ val = _validate_type("fancy", val, bool)
272
+ self._fancy = _obj_setter(self, self._fancy, val, self._fancy_style)
273
+
274
+ # fancy_style
275
+ @property
276
+ def fancy_style(self):
277
+ return self._fancy_style
278
+
279
+ @fancy_style.setter
280
+ def fancy_style(self, val: dict):
281
+ val = _validate_type("fancy_style", val, dict)
282
+ val = _validate_dict(val, _VALIDATE_FANCY, return_clean=True)
283
+ self._fancy_style = self._fancy_style | val
284
+ if self._fancy:
285
+ self._fancy = ArrowBase(self, **self._fancy_style)
286
+
287
+ # label
288
+ @property
289
+ def label(self):
290
+ return self._label
291
+
292
+ @label.setter
293
+ def label(self, val: bool):
294
+ val = _validate_type("label", val, bool)
295
+ self._label = _obj_setter(self, self._label, val, self._label_style)
296
+
297
+ # label_style
298
+ @property
299
+ def label_style(self):
300
+ return self._label_style
301
+
302
+ @label_style.setter
303
+ def label_style(self, val: dict):
304
+ val = _validate_type("label_style", val, dict)
305
+ val = _validate_dict(val, _VALIDATE_LABEL, return_clean=True)
306
+ self._label_style = self._label_style | val
307
+ if self._label:
308
+ self._label = ArrowBase(self, **self._label_style)
309
+
310
+ # shadow
311
+ @property
312
+ def shadow(self):
313
+ return self._shadow
314
+
315
+ @shadow.setter
316
+ def shadow(self, val: bool):
317
+ val = _validate_type("shadow", val, bool)
318
+ self._shadow = _obj_setter(self, self._shadow, val, self._shadow_style)
319
+
320
+ # shadow_style
321
+ @property
322
+ def shadow_style(self):
323
+ return self._shadow_style
324
+
325
+ @shadow_style.setter
326
+ def shadow_style(self, val: dict):
327
+ val = _validate_type("shadow_style", val, dict)
328
+ val = _validate_dict(val, _VALIDATE_SHADOW, return_clean=True)
329
+ self._shadow_style = self._shadow_style | val
330
+ if self._shadow:
331
+ self._shadow = ArrowBase(self, **self._shadow_style)
332
+
333
+ # packer_style
334
+ @property
335
+ def packer_style(self):
336
+ return self._packer_style
337
+
338
+ @packer_style.setter
339
+ def packer_style(self, val: dict):
340
+ val = _validate_type("packer_style", val, dict)
341
+ val = _validate_dict(val, _VALIDATE_PACK, return_clean=True)
342
+ self._packer_style = self._packer_style | val
343
+
344
+ # aob_style
345
+ @property
346
+ def aob_style(self):
347
+ return self._aob_style
348
+
349
+ @aob_style.setter
350
+ def aob_style(self, val: dict):
351
+ val = _validate_type("aob_style", val, dict)
352
+ val = _validate_dict(val, _VALIDATE_AOB, return_clean=True)
353
+ self._aob_style = self._aob_style | val
354
+
355
+ ## COPY FUNCTION ##
356
+ # This is solely to get around matplotlib's restrictions around re-using an artist across multiple axes
357
+ # Instead, you can use add_artist() like normal, but with add_artist(na.copy())
358
+ def copy(self):
359
+ return copy.deepcopy(self)
360
+
361
+ ## DRAW FUNCTION ##
362
+ # Calling ax.add_artist() on this object triggers the following draw() function
363
+ # THANK YOU to matplotlib-scalebar for figuring this out
364
+ # Note that we never specify the renderer - the axis takes care of it!
365
+ def draw(self, renderer, *args, **kwargs):
366
+ # Can re-use the drawing function we already established, but return the object instead
367
+ na_artist = north_arrow(ax=self.axes, draw=False,
368
+ base=_draw_obj(self._base), base_style=_class_dict(self._base, "parent"),
369
+ fancy=_draw_obj(self._fancy), fancy_style=_class_dict(self._fancy, "parent"),
370
+ label=_draw_obj(self._label), label_style=_class_dict(self._label, "parent"),
371
+ shadow=_draw_obj(self._shadow), shadow_style=_class_dict(self._shadow, "parent"),
372
+ packer_style=self._packer_style, aob_style=self._aob_style, rotation_style=self._rotation_style)
373
+ # This handles the actual drawing
374
+ na_artist.axes = self.axes
375
+ na_artist.set_figure(self.axes.get_figure())
376
+ na_artist.draw(renderer)
377
+
378
+ # Class constructor for the base of the north arrow
379
+ class ArrowBase:
380
+ # Initialization upon first creation
381
+ # Notice that these are all the keys of the _DEFAULT_BASE dictionary!
382
+ # Except for Parent, which is our NorthArrow class
383
+ # TODO: Do you have to make the parent call a weakref?
384
+ # See: https://stackoverflow.com/questions/10791588/getting-container-parent-object-from-within-python
385
+ def __init__(self, parent, coords: numpy.array, location: str, scale: float | int, facecolor: str, edgecolor: str, linewidth: float | int, zorder: int):
386
+ self._coords = _validate(_VALIDATE_BASE, "coords", coords)
387
+ self._location = _validate(_VALIDATE_BASE, "location", location)
388
+ self._scale = _validate(_VALIDATE_BASE, "scale", scale)
389
+ self._facecolor = _validate(_VALIDATE_BASE, "facecolor", facecolor)
390
+ self._edgecolor = _validate(_VALIDATE_BASE, "edgecolor", edgecolor)
391
+ self._linewidth = _validate(_VALIDATE_BASE, "linewidth", linewidth)
392
+ self._zorder = _validate(_VALIDATE_BASE, "zorder", zorder)
393
+ self._parent = parent
394
+
395
+ ## INTERNAL PROPERTIES ##
396
+ # coords
397
+ @property
398
+ def coords(self):
399
+ return self._coords
400
+
401
+ @coords.setter
402
+ def coords(self, val):
403
+ val = _validate(_VALIDATE_BASE, "coords", val)
404
+ self._coords = val
405
+
406
+ # location
407
+ @property
408
+ def location(self):
409
+ return self._location
410
+
411
+ @location.setter
412
+ def location(self, val):
413
+ val = _validate(_VALIDATE_BASE, "location", val)
414
+ self._location = val
415
+
416
+ # scale
417
+ @property
418
+ def scale(self):
419
+ return self._scale
420
+
421
+ @scale.setter
422
+ def scale(self, val):
423
+ val = _validate(_VALIDATE_BASE, "scale", val)
424
+ self._scale = val
425
+
426
+ # facecolor
427
+ @property
428
+ def facecolor(self):
429
+ return self._facecolor
430
+
431
+ @facecolor.setter
432
+ def facecolor(self, val):
433
+ val = _validate(_VALIDATE_BASE, "facecolor", val)
434
+ self._facecolor = val
435
+
436
+ # edgecolor
437
+ @property
438
+ def edgecolor(self):
439
+ return self._edgecolor
440
+
441
+ @edgecolor.setter
442
+ def edgecolor(self, val):
443
+ val = _validate(_VALIDATE_BASE, "edgecolor", val)
444
+ self._edgecolor = val
445
+
446
+ # linewidth
447
+ @property
448
+ def linewidth(self):
449
+ return self._linewidth
450
+
451
+ @linewidth.setter
452
+ def linewidth(self, val):
453
+ val = _validate(_VALIDATE_BASE, "linewidth", val)
454
+ self._linewidth = val
455
+
456
+ # # rotation
457
+ # @property
458
+ # def rotation(self):
459
+ # return self._rotation
460
+
461
+ # @rotation.setter
462
+ # def rotation(self, val):
463
+ # val = _validate(_VALIDATE_BASE, "rotation", val)
464
+ # if self._crs is not None:
465
+ # warnings.warn(f"Setting the rotation overrides the current CRS value of {self._crs}")
466
+ # self._crs = None
467
+ # self._rotation = val
468
+ # elif self._crs is None and val is None:
469
+ # raise ValueError(f"One of rotation or CRS must be set; keeping rotation at current value")
470
+ # else:
471
+ # self._rotation = val
472
+
473
+ # # crs
474
+ # @property
475
+ # def crs(self):
476
+ # return self._crs
477
+
478
+ # @crs.setter
479
+ # def crs(self, val):
480
+ # if self._rotation is not None:
481
+ # warnings.warn(f"Setting the CRS overrides the current rotation value of {self._rotation}")
482
+ # self._rotation = None
483
+ # val = _validate(_VALIDATE_BASE, "crs", val, kwargs={"rotation":self._rotation})
484
+ # self._crs = val
485
+ # elif self._rotation is None and val is None:
486
+ # raise ValueError(f"One of rotation or CRS must be set; keeping CRS at current value")
487
+ # else:
488
+ # self._crs = val
489
+
490
+ # # rotation_ref
491
+ # @property
492
+ # def rotation_ref(self):
493
+ # return self._rotation_ref
494
+
495
+ # @rotation_ref.setter
496
+ # def rotation_ref(self, val):
497
+ # val = _validate(_VALIDATE_BASE, "rotation_ref", val)
498
+ # self._rotation_ref = val
499
+
500
+ # zorder
501
+ @property
502
+ def zorder(self):
503
+ return self._zorder
504
+
505
+ @zorder.setter
506
+ def zorder(self, val):
507
+ # val = _validate_type("zorder", val, int)
508
+ val = _validate(_VALIDATE_BASE, "zorder", val)
509
+ self._zorder = val
510
+
511
+ # Class constructor for the "fancy" part of the north arrow (the right hand patch)
512
+ class ArrowFancy:
513
+ # Initialization upon first creation
514
+ # Notice that these are all the keys of the _DEFAULT_FANCY dictionary!
515
+ def __init__(self, parent, coords: numpy.array, facecolor: str, zorder: int):
516
+ self._coords = _validate(_VALIDATE_FANCY, "coords", coords)
517
+ self._facecolor = _validate(_VALIDATE_FANCY, "facecolor", facecolor)
518
+ self._zorder = _validate(_VALIDATE_FANCY, "zorder", zorder)
519
+ self._parent = parent
520
+
521
+ ## INTERNAL PROPERTIES ##
522
+ # coords
523
+ @property
524
+ def coords(self):
525
+ return self._coords
526
+
527
+ @coords.setter
528
+ def coords(self, val):
529
+ val =_validate(_VALIDATE_FANCY, "coords", val)
530
+ self._coords = val
531
+
532
+ # facecolor
533
+ @property
534
+ def facecolor(self):
535
+ return self._facecolor
536
+
537
+ @facecolor.setter
538
+ def facecolor(self, val):
539
+ val =_validate(_VALIDATE_FANCY, "facecolor", val)
540
+ self._facecolor = val
541
+
542
+ # zorder
543
+ @property
544
+ def zorder(self):
545
+ return self._zorder
546
+
547
+ @zorder.setter
548
+ def zorder(self, val):
549
+ val =_validate(_VALIDATE_FANCY, "zorder", val)
550
+ self._zorder = val
551
+
552
+ # Class constructor for the label of the north arrow (traditionally, the letter N)
553
+ # TODO: Get Rotation working on this one, which is a part of the textprops dict actually
554
+ class ArrowLabel:
555
+ # Initialization upon first creation
556
+ # Notice that these are all the keys of the _DEFAULT_FANCY dictionary!
557
+ def __init__(self, parent, text: str, position: str, ha: str, va: str, fontsize: str | float | int, fontfamily: str, fontstyle: str, color: str, fontweight: str, stroke_width: float | int, stroke_color: str, rotation: float | int, zorder: int):
558
+ self._text = _validate(_VALIDATE_LABEL, "text", text)
559
+ self._position = _validate(_VALIDATE_LABEL, "position", position)
560
+ self._ha = _validate(_VALIDATE_LABEL, "ha", ha)
561
+ self._va = _validate(_VALIDATE_LABEL, "va", va)
562
+ self._fontsize = _validate(_VALIDATE_LABEL, "fontsize", fontsize)
563
+ self._fontfamily = _validate(_VALIDATE_LABEL, "fontfamily", fontfamily)
564
+ self._fontstyle = _validate(_VALIDATE_LABEL, "fontstyle", fontstyle)
565
+ self._color = _validate(_VALIDATE_LABEL, "color", color)
566
+ self._fontweight = _validate(_VALIDATE_LABEL, "fontweight", fontweight)
567
+ self._stroke_width = _validate(_VALIDATE_LABEL, "stroke_width", stroke_width)
568
+ self._stroke_color = _validate(_VALIDATE_LABEL, "stroke_color", stroke_color)
569
+ self._rotation = _validate(_VALIDATE_LABEL, "rotation", rotation)
570
+ self._zorder = _validate(_VALIDATE_LABEL, "zorder", zorder)
571
+ self._parent = parent
572
+
573
+ ## INTERNAL PROPERTIES ##
574
+ # text
575
+ @property
576
+ def text(self):
577
+ return self._text
578
+
579
+ @text.setter
580
+ def text(self, val):
581
+ val = _validate(_VALIDATE_LABEL, "text", val)
582
+ self._text = val
583
+
584
+ # position
585
+ @property
586
+ def position(self):
587
+ return self._position
588
+
589
+ @position.setter
590
+ def position(self, val):
591
+ val = _validate(_VALIDATE_LABEL, "position", val)
592
+ self._position = val
593
+
594
+ # ha
595
+ @property
596
+ def ha(self):
597
+ return self._ha
598
+
599
+ @ha.setter
600
+ def ha(self, val):
601
+ val = _validate(_VALIDATE_LABEL, "ha", val)
602
+ self._ha = val
603
+
604
+ # va
605
+ @property
606
+ def va(self):
607
+ return self._va
608
+
609
+ @va.setter
610
+ def va(self, val):
611
+ val = _validate(_VALIDATE_LABEL, "va", val)
612
+ self._va = val
613
+
614
+ # fontsize
615
+ @property
616
+ def fontsize(self):
617
+ return self._fontsize
618
+
619
+ @fontsize.setter
620
+ def fontsize(self, val):
621
+ val = _validate(_VALIDATE_LABEL, "fontsize", val)
622
+ self._fontsize = val
623
+
624
+ # fontfamily
625
+ @property
626
+ def fontfamily(self):
627
+ return self._fontfamily
628
+
629
+ @fontfamily.setter
630
+ def fontfamily(self, val):
631
+ val = _validate(_VALIDATE_LABEL, "fontfamily", val)
632
+ self._fontfamily = val
633
+
634
+ # fontstyle
635
+ @property
636
+ def fontstyle(self):
637
+ return self._fontstyle
638
+
639
+ @fontstyle.setter
640
+ def fontstyle(self, val):
641
+ val = _validate(_VALIDATE_LABEL, "fontstyle", val)
642
+ self._fontstyle = val
643
+
644
+ # color
645
+ @property
646
+ def color(self):
647
+ return self._color
648
+
649
+ @color.setter
650
+ def color(self, val):
651
+ val = _validate(_VALIDATE_LABEL, "color", val)
652
+ self._color = val
653
+
654
+ # fontweight
655
+ @property
656
+ def fontweight(self):
657
+ return self._fontweight
658
+
659
+ @fontweight.setter
660
+ def fontweight(self, val):
661
+ val = _validate(_VALIDATE_LABEL, "fontweight", val)
662
+ self._fontweight = val
663
+
664
+ # stroke_width
665
+ @property
666
+ def stroke_width(self):
667
+ return self._stroke_width
668
+
669
+ @stroke_width.setter
670
+ def stroke_width(self, val):
671
+ val = _validate(_VALIDATE_LABEL, "stroke_width", val)
672
+ self._stroke_width = val
673
+
674
+ # stroke_color
675
+ @property
676
+ def stroke_color(self):
677
+ return self._stroke_color
678
+
679
+ @stroke_color.setter
680
+ def stroke_color(self, val):
681
+ val = _validate(_VALIDATE_LABEL, "stroke_color", val)
682
+ self._stroke_color = val
683
+
684
+ # rotation
685
+ @property
686
+ def rotation(self):
687
+ return self._rotation
688
+
689
+ @rotation.setter
690
+ def rotation(self, val):
691
+ val = _validate(_VALIDATE_LABEL, "rotation", val)
692
+ self._rotation = val
693
+
694
+ # zorder
695
+ @property
696
+ def zorder(self):
697
+ return self._zorder
698
+
699
+ @zorder.setter
700
+ def zorder(self, val):
701
+ val = _validate(_VALIDATE_LABEL, "zorder", val)
702
+ self._zorder = val
703
+
704
+ # Class constructor for the shadow underneath the base of the north arrow (the right hand patch)
705
+ class ArrowShadow:
706
+ # Initialization upon first creation
707
+ # Notice that these are all the keys of the _DEFAULT_FANCY dictionary!
708
+ def __init__(self, parent, offset: tuple[float | int, float | int], alpha: float | int, shadow_rgbFace: str):
709
+ self._offset = _validate(_VALIDATE_SHADOW, "offset", offset)
710
+ self._alpha = _validate(_VALIDATE_SHADOW, "alpha", alpha)
711
+ self._shadow_rgbFace = _validate(_VALIDATE_SHADOW, "shadow_rgbFace", shadow_rgbFace)
712
+ self._parent = parent
713
+
714
+ ## INTERNAL PROPERTIES ##
715
+ # offset
716
+ @property
717
+ def offset(self):
718
+ return self._offset
719
+
720
+ @offset.setter
721
+ def offset(self, val):
722
+ val = _validate(_VALIDATE_SHADOW, "offset", val)
723
+ self._offset = val
724
+
725
+ # alpha
726
+ @property
727
+ def alpha(self):
728
+ return self._alpha
729
+
730
+ @alpha.setter
731
+ def alpha(self, val):
732
+ val = _validate(_VALIDATE_SHADOW, "alpha", val)
733
+ self._alpha = val
734
+
735
+ # zorder
736
+ @property
737
+ def shadow_rgbFace(self):
738
+ return self._shadow_rgbFace
739
+
740
+ @shadow_rgbFace.setter
741
+ def shadow_rgbFace(self, val):
742
+ val = _validate(_VALIDATE_SHADOW, "shadow_rgbFace", val)
743
+ self._shadow_rgbFace = val
744
+
745
+ ### DRAWING FUNCTIONS ###
746
+ # These functions take care of the actual drawing of the object!
747
+ # You can use them independently of the class-based model
748
+ def north_arrow(ax, draw=True,
749
+ base: bool=True, base_style: _TYPE_BASE=_DEFAULT_BASE,
750
+ fancy: bool=True, fancy_style: _TYPE_FANCY=_DEFAULT_FANCY,
751
+ label: bool=True, label_style: _TYPE_LABEL=_DEFAULT_LABEL,
752
+ shadow: bool=True, shadow_style: _TYPE_SHADOW=_DEFAULT_SHADOW,
753
+ packer_style: _TYPE_PACK=_DEFAULT_PACK, aob_style: _TYPE_AOB=_DEFAULT_AOB, rotation_style: _TYPE_ROTATION=_DEFAULT_ROTATION):
754
+
755
+ # Setting the styles for each component
756
+ # The dict-concatenation ensures we always have SOMETHING available for each necessary attribute
757
+ # But it means any overrides (to color, size, font, etc.) have to be manually specified
758
+ _base_style = _DEFAULT_BASE | base_style
759
+ _fancy_style = _DEFAULT_FANCY | fancy_style
760
+ _label_style = _DEFAULT_LABEL | label_style
761
+ _shadow_style = _DEFAULT_SHADOW | shadow_style
762
+ _packer_style = _DEFAULT_PACK | packer_style
763
+ _aob_style = _DEFAULT_AOB | aob_style
764
+ _rotation_style = _DEFAULT_ROTATION | rotation_style
765
+
766
+ # Validating that each element in each dictionary is valid
767
+ for d,v in zip([_base_style, _fancy_style, _label_style, _shadow_style, _packer_style, _aob_style],
768
+ [_VALIDATE_BASE, _VALIDATE_FANCY, _VALIDATE_LABEL, _VALIDATE_SHADOW, _VALIDATE_PACK, _VALIDATE_AOB]):
769
+ # Getting validate-able keys
770
+ to_validate = [k for k in d.keys() if k in v.keys()]
771
+ # Using our handy-dandy function to validate
772
+ _ = _validate_dict(d, v, to_validate)
773
+
774
+ # First, getting the figure for our axes
775
+ fig = ax.get_figure()
776
+
777
+ # We will place the arrow components in an AuxTransformBox so they are scaled in inches
778
+ # Props to matplotlib-scalebar
779
+ scale_box = matplotlib.offsetbox.AuxTransformBox(fig.dpi_scale_trans)
780
+
781
+ ## BASE ARROW ##
782
+ # Because everything is dependent on this component, it ALWAYS exists
783
+ # However, if we don't want it (base=False), then we'll hide it
784
+ base_artist = matplotlib.patches.Polygon(_base_style["coords"] * _base_style["scale"], closed=True, visible=base, **_del_keys(_base_style, ["coords","scale","location","rotation","crs","rotation_ref"]))
785
+
786
+ ## ARROW SHADOW ##
787
+ # This is not its own artist, but instead just something we modify about the base artist using a path effect
788
+ if shadow==True:
789
+ base_artist.set_path_effects([matplotlib.patheffects.withSimplePatchShadow(**_shadow_style)])
790
+
791
+ # With our base arrow "done", we can add it to scale_box
792
+ scale_box.add_artist(base_artist)
793
+
794
+ ## FANCY ARROW ##
795
+ # If we want the fancy extra patch, we need another artist
796
+ if fancy==True:
797
+ # Note that here, unfortunately, we are reliant on the scale attribute from the base arrow
798
+ fancy_artist = matplotlib.patches.Polygon(_fancy_style["coords"] * _base_style["scale"], closed=True, visible=fancy, **_del_keys(_fancy_style, ["coords"]))
799
+ # It is also added to the scale_box so it is scaled in-line
800
+ scale_box.add_artist(fancy_artist)
801
+
802
+ ## LABEL ##
803
+ # The final artist is for the label
804
+ if label==True:
805
+ # Correctly constructing the textprops dict for the label
806
+ text_props = _del_keys(_label_style, ["text","position","stroke_width","stroke_color"])
807
+ # If we have stroke settings, create a path effect for them
808
+ if _label_style["stroke_width"] > 0:
809
+ label_stroke = [matplotlib.patheffects.withStroke(linewidth=_label_style["stroke_width"], foreground=_label_style["stroke_color"])]
810
+ text_props["path_effects"] = label_stroke
811
+ # This one is not added to the scale box, as that is handled on its own
812
+ # Also, the dictionary does not need to be unpacked, textprops does that for us
813
+ label_box = matplotlib.offsetbox.TextArea(_label_style["text"], textprops=text_props)
814
+
815
+ ## STACKING THE ARTISTS ##
816
+ # If we have multiple artists, we need to stack them using a V or H packer
817
+ if label==True and (base==True or fancy==True):
818
+ if _label_style["position"]=="top":
819
+ pack_box = matplotlib.offsetbox.VPacker(children=[label_box, scale_box], **_packer_style)
820
+ elif _label_style["position"]=="bottom":
821
+ pack_box = matplotlib.offsetbox.VPacker(children=[scale_box, label_box], **_packer_style)
822
+ elif _label_style["position"]=="left":
823
+ pack_box = matplotlib.offsetbox.HPacker(children=[label_box, scale_box], **_packer_style)
824
+ elif _label_style["position"]=="right":
825
+ pack_box = matplotlib.offsetbox.HPacker(children=[scale_box, label_box], **_packer_style)
826
+ else:
827
+ raise Exception("Invalid position applied, try one of 'top', 'bottom', 'left', 'right'")
828
+ # If we only have the base, then that's the only thing we'll add to the box
829
+ else:
830
+ pack_box = matplotlib.offsetbox.VPacker(children=[scale_box], **_packer_style)
831
+
832
+ ## CREATING THE OFFSET BOX ##
833
+ # The AnchoredOffsetBox allows us to place the pack_box relative to our axes
834
+ # Note that the position string (upper left, lower right, center, etc.) comes from the base arrow
835
+ aob_box = matplotlib.offsetbox.AnchoredOffsetbox(loc=_base_style["location"], child=pack_box, **_del_keys(_aob_style, ["facecolor","edgecolor","alpha"]))
836
+ # Also setting the facecolor and transparency of the box
837
+ if _aob_style["facecolor"] is not None:
838
+ aob_box.patch.set_facecolor(_aob_style["facecolor"])
839
+ aob_box.patch.set_visible(True)
840
+ if _aob_style["edgecolor"] is not None:
841
+ aob_box.patch.set_edgecolor(_aob_style["edgecolor"])
842
+ aob_box.patch.set_visible(True)
843
+ if _aob_style["alpha"]:
844
+ aob_box.patch.set_alpha(_aob_style["alpha"])
845
+ aob_box.patch.set_visible(True)
846
+
847
+ ## ROTATING THE ARROW ##
848
+ # If no rotation amount is passed, (attempt to) calculate it
849
+ if _rotation_style["degrees"] is None:
850
+ rotate_deg = _rotate_arrow(ax, _rotation_style)
851
+ else:
852
+ rotate_deg = _rotation_style["degrees"]
853
+ # Then, apply the rotation to the aob box
854
+ _iterative_rotate(aob_box, rotate_deg)
855
+
856
+ ## DRAWING ##
857
+ # If this option is set to true, we'll draw the final artists as desired
858
+ if draw==True:
859
+ ax.add_artist(aob_box)
860
+ # If not, we'll return the aob_box as an object
861
+ else:
862
+ return aob_box
863
+
864
+ # This function calculates the desired rotation of the arrow
865
+ def _rotate_arrow(ax, rotate_dict) -> float | int:
866
+ crs = rotate_dict["crs"]
867
+ ref = rotate_dict["reference"]
868
+ crd = rotate_dict["coords"] # should be (x,y) for axis ref, (lat,lng) for data ref
869
+
870
+ ## CONVERTING FROM AXIS TO DATA COORDAINTES ##
871
+ # If reference is set to axis, need to convert the axis coordinates (ranging from 0 to 1) to data coordinates (in native crs)
872
+ if ref=="axis":
873
+ # the transLimits transformation is for converting between data and axes units
874
+ # so this code gets us the data (geographic) units of the chosen axis coordinates
875
+ reference_point = ax.transLimits.inverted().transform((crd[0], crd[1]))
876
+ # If reference is set to center, then do the same thing, but use the midpoint of the axis by default
877
+ elif ref=="center":
878
+ reference_point = ax.transLimits.inverted().transform((0.5,0.5))
879
+ # Finally if the reference is set to data, we assume the provided coordinates are already in data units, no transformation needed!
880
+ elif ref=="data":
881
+ reference_point = crd
882
+
883
+ ## CONVERTING TO GEODETIC COORDINATES ##
884
+ # Initializing a CRS, so we can transform between coordinate systems appropriately
885
+ if type(crs) == pyproj.CRS:
886
+ og_proj = cartopy.crs.CRS(crs)
887
+ else:
888
+ try:
889
+ og_proj = cartopy.crs.CRS(pyproj.CRS(crs))
890
+ except:
891
+ raise Exception("Invalid CRS Supplied")
892
+ # Converting to the geodetic version of the CRS supplied
893
+ gd_proj = og_proj.as_geodetic()
894
+ # Converting reference point to the geodetic system
895
+ reference_point_gd = gd_proj.transform_point(reference_point[0], reference_point[1], og_proj)
896
+ # Converting the coordinates BACK to the original system
897
+ reference_point = og_proj.transform_point(reference_point_gd[0], reference_point_gd[1], gd_proj)
898
+ # And adding an offset to find "north", relative to that
899
+ north_point = og_proj.transform_point(reference_point_gd[0], reference_point_gd[1] + 0.01, gd_proj)
900
+
901
+ ## CALCULATING THE ANGLE ##
902
+ # numpy.arctan2 wants coordinates in (y,x) because it flips them when doing the calculation
903
+ # 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)
904
+ try:
905
+ rad = -1 * numpy.arctan2(north_point[0] - reference_point[0], north_point[1] - reference_point[1])
906
+ except:
907
+ warnings.warn("Unable to calculate rotation of arrow, setting to 0 degrees")
908
+ rad = 0
909
+ # Converting radians to degrees
910
+ deg = math.degrees(rad)
911
+ print(deg)
912
+ # Returning the degree number
913
+ return deg
914
+
915
+ # Unfortunately, matplotlib doesn't allow AnchoredOffsetBoxes or V/HPackers to have a rotation transformation
916
+ # So, we have to set it on the individual child objects (namely the base arrow and fancy arrow patches)
917
+ def _iterative_rotate(artist, deg):
918
+ # Building the affine rotation transformation
919
+ transform_rotation = matplotlib.transforms.Affine2D().rotate_deg(deg)
920
+ artist.set_transform(transform_rotation + artist.get_transform())
921
+ if artist.get_children():
922
+ for child in artist.get_children():
923
+ _iterative_rotate(child, deg)
924
+
925
+ ### HELPING FUNTIONS ###
926
+ # These are quick functions we use to help in other parts of this process
927
+
928
+ # This function will handle updating setter functions
929
+ def _obj_setter(oself, oval, nval, ostyle):
930
+ # If we currently DON'T have this object, and set it to true, we need to create it
931
+ # We can leverage the current _style dict to create it
932
+ if oval==False and nval==True:
933
+ return ArrowBase(oself, **ostyle)
934
+ # If we currently have this object, but set it to false, we need to delete it
935
+ elif oval and nval==False:
936
+ return nval
937
+ # If things stay the same, no need to do anything at all
938
+ else:
939
+ return oself
940
+
941
+ # This function will remove any keys we specify from a dictionary
942
+ # This is useful if we need to unpack on certain values from a dictionary!
943
+ def _del_keys(dict, to_remove):
944
+ return {key: val for key, val in dict.items() if key not in to_remove}
945
+
946
+ # This function takes in a class, and returns its attribute dictionary
947
+ # The dictionary also has all of its leading underscores removed
948
+ def _class_dict(obj, to_remove=None):
949
+ # Note that we wrap this in a try/except
950
+ # For the cases where we just have a False instead of an initialized class
951
+ try:
952
+ dict = obj.__dict__
953
+ clean = {key[1:]: val for key, val in dict.items()}
954
+ if to_remove:
955
+ clean = _del_keys(clean, to_remove)
956
+ return clean
957
+ except:
958
+ return {}
959
+
960
+ # This function just returns true/false depending on if the object exists
961
+ def _draw_obj(obj):
962
+ if obj == False:
963
+ return False
964
+ else:
965
+ return True
966
+
967
+ ### VALIDITY CHECKERS ###
968
+ # Functions and variables used for validating inputs for classes
969
+ def _validate_list(prop, val, list, none_ok=False):
970
+ if none_ok==False and val is None:
971
+ raise ValueError(f"None is not a valid value for {prop}, please provide a value in this list: {list}")
972
+ elif none_ok==True and val is None:
973
+ return val
974
+ elif not val in list:
975
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide a value in this list: {list}")
976
+ return val
977
+
978
+ def _validate_range(prop, val, min, max, none_ok=False):
979
+ if none_ok==False and val is None:
980
+ raise ValueError(f"None is not a valid value for {prop}, please provide a value between {min} and {max}")
981
+ elif none_ok==True and val is None:
982
+ return val
983
+ elif max is not None:
984
+ if not val >= min and not val <= max:
985
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide a value between {min} and {max}")
986
+ elif max is None:
987
+ if not val >= min:
988
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide a value greater than {min}")
989
+ return val
990
+
991
+ def _validate_type(prop, val, match, none_ok=False):
992
+ if none_ok==False and val is None:
993
+ raise ValueError(f"None is not a valid value for {prop}, please provide an object of type {match}")
994
+ elif none_ok==True and val is None:
995
+ return val
996
+ elif not type(val)==match:
997
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide an object of type {match}")
998
+ return val
999
+
1000
+ def _validate_coords(prop, val, numpy_type, dims, none_ok=False):
1001
+ if none_ok==False and val is None:
1002
+ raise ValueError(f"None is not a valid value for {prop}, please provide an object of type {numpy_type}")
1003
+ elif none_ok==True and val is None:
1004
+ return val
1005
+ elif not type(val)==numpy_type:
1006
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide an object of type {numpy_type}")
1007
+ elif not val.ndim==dims:
1008
+ raise ValueError(f"'{val}' is not a valid value for {prop}, please provide a numpy array with {dims} dimensions")
1009
+ return val
1010
+
1011
+ def _validate_tuple(prop, val, length, types, none_ok=False):
1012
+ if none_ok==False and val is None:
1013
+ raise ValueError(f"None is not a valid value for {prop}, please provide a tuple of length {length} instead")
1014
+ elif none_ok==True and val is None:
1015
+ return val
1016
+ elif type(val)!=tuple:
1017
+ raise ValueError(f"{val} is not a valid value for {prop}, please provide a tuple of length {length} instead")
1018
+ elif len(val)!=length:
1019
+ raise ValueError(f"{val} is not a valid value for {prop}, please provide a tuple of length {length} instead")
1020
+ else:
1021
+ for item in val:
1022
+ if type(item) not in types:
1023
+ raise ValueError(f"{type(item)} is not a valid value for the items in {prop}, please provide a value of one of the following types: {types}")
1024
+ return val
1025
+
1026
+ def _validate_color_or_none(prop, val, none_ok=False):
1027
+ if none_ok==False and val is None:
1028
+ raise ValueError(f"None is not a valid value for {prop}, please provide a color string acceptable to matplotlib instead")
1029
+ elif none_ok==True and val is None:
1030
+ return val
1031
+ else:
1032
+ matplotlib.rcsetup.validate_color(val)
1033
+ return val
1034
+
1035
+ # NOTE: This one is a bit messy, particularly with the rotation module, but I can't think of a better way to do it...
1036
+ def _validate_crs(prop, val, rotation_dict, none_ok=False):
1037
+ degrees = rotation_dict.get("degrees",None)
1038
+ crs = rotation_dict.get("crs",None)
1039
+ reference = rotation_dict.get("reference",None)
1040
+ coords = rotation_dict.get("coords",None)
1041
+ print(degrees, crs, reference, coords)
1042
+ if degrees is None:
1043
+ if crs is None or reference is None or coords is None:
1044
+ raise ValueError(f"If degrees is set to None, then crs, reference, and coords cannot be None: please provide a valid input for each of these variables instead")
1045
+ elif (type(degrees)==int or type(degrees)==float) and (crs is None or reference is None or coords is None):
1046
+ warnings.warn(f"A value for rotation was supplied; values for crs, reference, and coords will be ignored")
1047
+ return val
1048
+ else:
1049
+ if none_ok==False and val is None:
1050
+ raise ValueError(f"If rotation is set to None, then {prop} cannot be None: please provide a valid CRS input for PyProj instead")
1051
+ elif none_ok==True and val is None:
1052
+ return val
1053
+ # This happens if (a) a value for CRS is supplied and (b) a value for degrees is NOT supplied
1054
+ if type(val)==pyproj.CRS:
1055
+ pass
1056
+ else:
1057
+ try:
1058
+ val = pyproj.CRS.from_user_input(val)
1059
+ except:
1060
+ raise Exception(f"Invalid CRS supplied ({val}), please provide a valid CRS input for PyProj instead")
1061
+ return val
1062
+
1063
+ ## Set-up for each dictionary's specific validation rules
1064
+
1065
+ _VALID_BASE_LOCATIONS = get_args(_TYPE_BASE.__annotations__["location"])
1066
+
1067
+ _VALIDATE_BASE = {
1068
+ "coords":{"func":_validate_coords, "kwargs":{"numpy_type":numpy.ndarray, "dims":2}}, # must be 2D numpy array
1069
+ "location":{"func":_validate_list, "kwargs":{"list":_VALID_BASE_LOCATIONS}}, # can be https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.legend.html (see: loc)
1070
+ "scale":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1071
+ "facecolor":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1072
+ "edgecolor":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1073
+ "linewidth":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1074
+ "zorder":{"func":_validate_type, "kwargs":{"match":int}} # any integer
1075
+ }
1076
+
1077
+ _VALIDATE_FANCY = {
1078
+ "coords":{"func":_validate_coords, "kwargs":{"numpy_type":numpy.ndarray, "dims":2}}, # must be 2D numpy array
1079
+ "facecolor":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1080
+ "zorder":{"func":_validate_type, "kwargs":{"match":int}} # any integer
1081
+ }
1082
+
1083
+ _VALID_LABEL_POSITION = get_args(_TYPE_LABEL.__annotations__["position"])
1084
+ _VALID_LABEL_HA = get_args(_TYPE_LABEL.__annotations__["ha"])
1085
+ _VALID_LABEL_VA = get_args(_TYPE_LABEL.__annotations__["va"])
1086
+ _VALID_LABEL_FONTFAMILY = get_args(_TYPE_LABEL.__annotations__["fontfamily"])
1087
+ _VALID_LABEL_FONTSTYLE = get_args(_TYPE_LABEL.__annotations__["fontstyle"])
1088
+ _VALID_LABEL_FONTWEIGHT = get_args(_TYPE_LABEL.__annotations__["fontweight"])
1089
+
1090
+ _VALIDATE_LABEL = {
1091
+ "text":{"func":_validate_type, "kwargs":{"match":str}}, # any string
1092
+ "position":{"func":_validate_list, "kwargs":{"list":_VALID_LABEL_POSITION}},
1093
+ "ha":{"func":_validate_list, "kwargs":{"list":_VALID_LABEL_HA}},
1094
+ "va":{"func":_validate_list, "kwargs":{"list":_VALID_LABEL_VA}},
1095
+ "fontsize":{"func":matplotlib.rcsetup.validate_fontsize}, # any fontsize value for matplotlib
1096
+ "fontfamily":{"func":_validate_list, "kwargs":{"list":_VALID_LABEL_FONTFAMILY}},
1097
+ "fontstyle":{"func":_validate_list, "kwargs":{"list":_VALID_LABEL_FONTSTYLE}},
1098
+ "color":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1099
+ "fontweight":{"func":matplotlib.rcsetup.validate_fontweight}, # any fontweight value for matplotlib
1100
+ "stroke_width":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1101
+ "stroke_color":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1102
+ "rotation":{"func":_validate_range, "kwargs":{"min":-360, "max":360, "none_ok":True}}, # anything between -360 and 360, or None for "auto"
1103
+ "zorder":{"func":_validate_type, "kwargs":{"match":int}} # any integer
1104
+ }
1105
+
1106
+ _VALIDATE_SHADOW = {
1107
+ "offset":{"func":_validate_tuple, "kwargs":{"length":2, "types":[float, int]}},
1108
+ "alpha":{"func":_validate_range, "kwargs":{"min":0, "max":1, "none_ok":True}}, # any value between 0 and 1
1109
+ "shadow_rgbFace":{"func":matplotlib.rcsetup.validate_color}, # any color value for matplotlib
1110
+ }
1111
+
1112
+ _VALID_PACK_ALIGN = get_args(_TYPE_PACK.__annotations__["align"])
1113
+ _VALID_PACK_MODE = get_args(_TYPE_PACK.__annotations__["mode"])
1114
+
1115
+ _VALIDATE_PACK = {
1116
+ "sep":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1117
+ "align":{"func":_validate_list, "kwargs":{"list":_VALID_PACK_ALIGN}},
1118
+ "pad":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1119
+ "width":{"func":_validate_range, "kwargs":{"min":0, "max":None, "none_ok":True}}, # between 0 and inf
1120
+ "height":{"func":_validate_range, "kwargs":{"min":0, "max":None, "none_ok":True}}, # between 0 and inf
1121
+ "mode":{"func":_validate_list, "kwargs":{"list":_VALID_PACK_MODE}}
1122
+ }
1123
+
1124
+ _VALIDATE_AOB = {
1125
+ "facecolor":{"func":_validate_color_or_none, "kwargs":{"none_ok":True}}, # any color value for matplotlib OR NONE
1126
+ "edgecolor":{"func":_validate_color_or_none, "kwargs":{"none_ok":True}}, # any color value for matplotlib OR NONE
1127
+ "alpha":{"func":_validate_range, "kwargs":{"min":0, "max":1, "none_ok":True}}, # any value between 0 and 1
1128
+ "pad":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1129
+ "borderpad":{"func":_validate_range, "kwargs":{"min":0, "max":None}}, # between 0 and inf
1130
+ "prop":{"func":matplotlib.rcsetup.validate_fontsize}, # any fontsize value for matplotlib
1131
+ "frameon":{"func":_validate_type, "kwargs":{"match":bool}}, # any bool
1132
+ # "bbox_to_anchor":None, # TODO: Currently unvalidated! Make sure to remove from _validate_dict once updated!
1133
+ # "bbox_transform":None # TODO: Currently unvalidated! Make sure to remove from _validate_dict once updated!
1134
+ }
1135
+
1136
+ _VALID_ROTATION_REFERENCE = get_args(_TYPE_ROTATION.__annotations__["reference"])
1137
+
1138
+ _VALIDATE_ROTATION = {
1139
+ "degrees":{"func":_validate_range, "kwargs":{"min":-360, "max":360, "none_ok":True}}, # anything between -360 and 360, or None for "auto"
1140
+ "crs":{"func":_validate_crs, "kwargs":{"none_ok":True}}, # see _validate_crs for details on what is accepted
1141
+ "reference":{"func":_validate_list, "kwargs":{"list":_VALID_ROTATION_REFERENCE, "none_ok":True}}, # see _VALID_ROTATION_REFERENCE for accepted values
1142
+ "coords":{"func":_validate_tuple, "kwargs":{"length":2, "types":[float, int], "none_ok":True}} # only required if degrees is None: should be a tuple of coordinates in the relevant reference window
1143
+ }
1144
+
1145
+ # This function can process the _VALIDATE dictionaries we established above
1146
+ def _validate_dict(values, functions, to_validate: list=None, return_clean=False):
1147
+ # Pre-checking that no invalid keys are passed
1148
+ invalid = [key for key in values.keys() if key not in functions.keys() and key not in ["bbox_to_anchor", "bbox_transform"]]
1149
+ if len(invalid) > 0:
1150
+ print(f"Warning: Invalid keys detected ({invalid}). These will be ignored.")
1151
+ # First, trimming our values to only those we need to validate
1152
+ if to_validate is not None:
1153
+ values = {key: val for key, val in values.items() if key in to_validate}
1154
+ functions = {key: val for key, val in functions.items() if key in values.keys()}
1155
+ else:
1156
+ values = {key: val for key, val in values.items() if key in functions.keys()}
1157
+ functions = {key: val for key, val in functions.items() if key in values.keys()}
1158
+ # Now, running the function with the necessary kwargs
1159
+ for key,val in values.items():
1160
+ fd = functions[key]
1161
+ func = fd["func"]
1162
+ # NOTE: This is messy but the only way to get the rotation value to the crs function
1163
+ if key=="crs":
1164
+ _ = func(prop=key, val=val, rotation_dict=values, **fd["kwargs"])
1165
+ # Our custom functions always have this dictionary key in them, so we know what form they take
1166
+ elif "kwargs" in fd:
1167
+ _ = func(prop=key, val=val, **fd["kwargs"])
1168
+ # The matplotlib built-in functions DON'T have that, and only ever take the one value
1169
+ else:
1170
+ _ = func(val)
1171
+ if return_clean==True:
1172
+ return values
1173
+
1174
+ # This function can process the _VALIDATE dictionaries we established above, but for single variables at a time
1175
+ def _validate(validate_dict, prop, val, return_val=True, kwargs={}):
1176
+ fd = validate_dict[prop]
1177
+ func = fd["func"]
1178
+ # Our custom functions always have this dictionary key in them, so we know what form they take
1179
+ if "kwargs" in fd:
1180
+ val = func(prop=prop, val=val, **(fd["kwargs"] | kwargs))
1181
+ # The matplotlib built-in functions DON'T have that, and only ever take the one value
1182
+ else:
1183
+ val = func(val)
1184
+ if return_val==True:
1185
+ return val