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