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.
- matplotlib_map_utils/__init__.py +4 -0
- matplotlib_map_utils/defaults.py +416 -0
- matplotlib_map_utils/north_arrow.py +458 -0
- matplotlib_map_utils/scratch/map_utils.py +412 -0
- matplotlib_map_utils/scratch/north_arrow_old_classes.py +1185 -0
- matplotlib_map_utils/validation.py +332 -0
- matplotlib_map_utils-1.0.0.dist-info/LICENSE +674 -0
- matplotlib_map_utils-1.0.0.dist-info/METADATA +131 -0
- matplotlib_map_utils-1.0.0.dist-info/RECORD +11 -0
- matplotlib_map_utils-1.0.0.dist-info/WHEEL +5 -0
- matplotlib_map_utils-1.0.0.dist-info/top_level.txt +1 -0
@@ -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
|