matplotlib-map-utils 1.0.2__py3-none-any.whl → 2.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,1200 @@
1
+ ############################################################
2
+ # scale_bar.py contains all the main objects and functions
3
+ # for creating the scale bar artist rendered to plots
4
+ ############################################################
5
+
6
+ ### IMPORTING PACKAGES ###
7
+
8
+ # Default packages
9
+ import warnings
10
+ import math
11
+ import copy
12
+ import re
13
+ # Math packages
14
+ import numpy
15
+ # Geo packages
16
+ import cartopy
17
+ import pyproj
18
+ from great_circle_calculator.great_circle_calculator import distance_between_points
19
+ # Graphical packages
20
+ import PIL.Image
21
+ import matplotlib
22
+ import matplotlib.artist
23
+ import matplotlib.lines
24
+ import matplotlib.pyplot
25
+ import matplotlib.patches
26
+ import matplotlib.patheffects
27
+ import matplotlib.offsetbox
28
+ import matplotlib.transforms
29
+ import matplotlib.font_manager
30
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
31
+ # matplotlib's useful validation functions
32
+ import matplotlib.rcsetup
33
+ # The types we use in this script
34
+ from typing import Literal
35
+ # The information contained in our helper scripts (validation and defaults)
36
+ from ..defaults import scale_bar as sbd
37
+ from ..validation import scale_bar as sbt
38
+ from ..validation import functions as sbf
39
+
40
+ ### INIT ###
41
+
42
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["md"]
43
+
44
+ ### CLASSES ###
45
+
46
+ class ScaleBar(matplotlib.artist.Artist):
47
+
48
+ ## INITIALIZATION ##
49
+ def __init__(self, style: Literal["ticks","boxes"]="boxes",
50
+ location: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]="upper right",
51
+ bar: None | bool | sbt._TYPE_BAR=None,
52
+ units: None | bool | sbt._TYPE_UNITS=None,
53
+ labels: None | bool | sbt._TYPE_LABELS=None,
54
+ text: None | bool | sbt._TYPE_TEXT=None,
55
+ aob: None | bool | sbt._TYPE_AOB=None,
56
+ ):
57
+ # Starting up the object with the base properties of a matplotlib Artist
58
+ matplotlib.artist.Artist.__init__(self)
59
+
60
+ # If a dictionary is passed to any of the elements, validate that it is "correct", and then store the information
61
+ # Note that we also merge the provided dict with the default style dict, so no keys are missing
62
+ # If a specific component is not desired, it should be set to False during initialization
63
+
64
+ ##### VALIDATING #####
65
+ style = sbf._validate(sbt._VALIDATE_PRIMARY, "style", style)
66
+ self._style = style
67
+
68
+ # Location is stored as just a string
69
+ location = sbf._validate(sbt._VALIDATE_PRIMARY, "location", location)
70
+ self._location = location
71
+
72
+
73
+ # Shared elements for both ticked and boxed bars
74
+ # This validation is dependent on the type of bar we are constructing
75
+ # So we modify the validation dictionary to remove the keys that are not relevant (throwing a warning if they exist in the input)
76
+ if self._style == "boxes":
77
+ bar = sbf._validate_dict(bar, _del_keys(_DEFAULT_BAR, ["minor_frac","tick_loc","basecolors","tickcolors","tickwidth"]),
78
+ _del_keys(sbt._VALIDATE_BAR, ["minor_frac","tick_loc","basecolors","tickcolors","tickwidth"]), return_clean=True, to_validate="input")
79
+ else:
80
+ bar = sbf._validate_dict(bar, _del_keys(_DEFAULT_BAR, ["facecolors","edgecolors","edgewidth"]),
81
+ _del_keys(sbt._VALIDATE_BAR, ["facecolors","edgecolors","edgewidth"]), return_clean=True, to_validate="input")
82
+ self._bar = bar
83
+
84
+ units = sbf._validate_dict(units, _DEFAULT_UNITS, sbt._VALIDATE_UNITS, return_clean=True, to_validate="input")
85
+ self._units = units
86
+
87
+ labels = sbf._validate_dict(labels, _DEFAULT_LABELS, sbt._VALIDATE_LABELS, return_clean=True, to_validate="input")
88
+ self._labels = labels
89
+
90
+ text = sbf._validate_dict(text, _DEFAULT_TEXT, sbt._VALIDATE_TEXT, return_clean=True, to_validate="input")
91
+ self._text = text
92
+
93
+ # pack = sbf._validate_dict(pack, _DEFAULT_PACK, sbt._VALIDATE_PACK, return_clean=True, to_validate="input")
94
+ # self._pack = pack
95
+
96
+ aob = sbf._validate_dict(aob, _DEFAULT_AOB, sbt._VALIDATE_AOB, return_clean=True, to_validate="input")
97
+ self._aob = aob
98
+
99
+ # We do set the zorder for our objects individually,
100
+ # but we ALSO set it for the entire artist, here
101
+ # Thank you to matplotlib-scalebar for this tip
102
+ zorder = 99
103
+
104
+ ## INTERNAL PROPERTIES ##
105
+ # This allows for easy-updating of properties
106
+ # Each property will have the same pair of functions
107
+ # 1) calling the property itself returns its dictionary (ScaleBar.bar will output {...})
108
+ # 2) passing a dictionary will update key values (ScaleBar.bar = {...} will update present keys)
109
+
110
+ # style
111
+ @property
112
+ def style(self):
113
+ return self._style
114
+
115
+ @style.setter
116
+ def style(self, val: Literal["boxes","ticks"]):
117
+ val = sbf._validate(sbt._VALIDATE_PRIMARY, "style", val)
118
+ self._style = val
119
+
120
+ # location/loc
121
+ @property
122
+ def location(self):
123
+ return self._location
124
+
125
+ @location.setter
126
+ def location(self, val: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]):
127
+ val = sbf._validate(sbt._VALIDATE_PRIMARY, "location", val)
128
+ self._location = val
129
+
130
+ @property
131
+ def loc(self):
132
+ return self._location
133
+
134
+ @loc.setter
135
+ def loc(self, val: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]):
136
+ val = sbf._validate(sbt._VALIDATE_PRIMARY, "location", val)
137
+ self._location = val
138
+
139
+ # bar
140
+ @property
141
+ def bar(self):
142
+ return self._bar
143
+
144
+ @bar.setter
145
+ def bar(self, val: dict):
146
+ val = sbf._validate_type("bar", val, dict)
147
+ if self._style == "boxes":
148
+ val = sbf._validate_dict(val, self._bar, _del_keys(sbt._VALIDATE_BAR, ["minor_frac","tick_loc","basecolors","tickcolors","tickwidth"]), return_clean=True, parse_false=False)
149
+ else:
150
+ val = sbf._validate_dict(val, self._bar, _del_keys(sbt._VALIDATE_BAR, ["facecolors","edgecolors","edgewidth"]), return_clean=True, parse_false=False)
151
+ self._bar = val
152
+
153
+ # units
154
+ @property
155
+ def units(self):
156
+ return self._units
157
+
158
+ @units.setter
159
+ def units(self, val: dict):
160
+ val = sbf._validate_type("units", val, dict)
161
+ val = sbf._validate_dict(val, self._units, sbt._VALIDATE_UNITS, return_clean=True, parse_false=False)
162
+ self._units = val
163
+
164
+ # labels
165
+ @property
166
+ def labels(self):
167
+ return self._labels
168
+
169
+ @labels.setter
170
+ def labels(self, val: dict):
171
+ val = sbf._validate_type("labels", val, dict)
172
+ val = sbf._validate_dict(val, self._labels, sbt._VALIDATE_LABELS, return_clean=True, parse_false=False)
173
+ self._labels = val
174
+
175
+ # text
176
+ @property
177
+ def text(self):
178
+ return self._text
179
+
180
+ @text.setter
181
+ def text(self, val: dict):
182
+ val = sbf._validate_type("text", val, dict)
183
+ val = sbf._validate_dict(val, self._text, sbt._VALIDATE_TEXT, return_clean=True, parse_false=False)
184
+ self._text = val
185
+
186
+ # aob
187
+ @property
188
+ def aob(self):
189
+ return self._aob
190
+
191
+ @aob.setter
192
+ def aob(self, val: dict):
193
+ val = sbf._validate_type("aob", val, dict)
194
+ val = sbf._validate_dict(val, self._aob, sbt._VALIDATE_AOB, return_clean=True, parse_false=False)
195
+ self._aob = val
196
+
197
+ ## COPY FUNCTION ##
198
+ # This is solely to get around matplotlib's restrictions around re-using an artist across multiple axes
199
+ # Instead, you can use add_artist() like normal, but with add_artist(na.copy())
200
+ # Thank you to the cartopy team for helping fix a bug with this!
201
+ def copy(self):
202
+ return copy.deepcopy(self)
203
+
204
+ ## DRAW FUNCTION ##
205
+ # Calling ax.add_artist() on this object triggers the following draw() function
206
+ # THANK YOU to matplotlib-scalebar for figuring this out
207
+ # Note that we never specify the renderer - the axis takes care of it!
208
+ def draw(self, renderer, *args, **kwargs):
209
+ # Can re-use the drawing function we already established, but return the object instead
210
+ sb_artist = scale_bar(ax=self.axes, style=self._style, location=self._location, draw=False,
211
+ bar=self._bar, units=self._units,
212
+ labels=self._labels, text=self._text, aob=self._aob)
213
+ # This handles the actual drawing
214
+ sb_artist.axes = self.axes
215
+ sb_artist.set_figure(self.axes.get_figure())
216
+ sb_artist.draw(renderer)
217
+
218
+ ## SIZE FUNCTION ##
219
+ # This function will update the default dictionaries used based on the size of map being created
220
+ # See defaults_sb.py for more information on the dictionaries used here
221
+ def set_size(size: Literal["xs","xsmall","x-small",
222
+ "sm","small",
223
+ "md","medium",
224
+ "lg","large",
225
+ "xl","xlarge","x-large"]):
226
+ # Bringing in our global default values to update them
227
+ global _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB
228
+ # Changing the global default values as required
229
+ if size.lower() in ["xs","xsmall","x-small"]:
230
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["xs"]
231
+ elif size.lower() in ["sm","small"]:
232
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["sm"]
233
+ elif size.lower() in ["md","medium"]:
234
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["md"]
235
+ elif size.lower() in ["lg","large"]:
236
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["lg"]
237
+ elif size.lower() in ["xl","xlarge","x-large"]:
238
+ _DEFAULT_BAR, _DEFAULT_LABELS, _DEFAULT_UNITS, _DEFAULT_TEXT, _DEFAULT_AOB = sbd._DEFAULTS_SB["xl"]
239
+ else:
240
+ raise ValueError("Invalid value supplied, try one of ['xsmall', 'small', 'medium', 'large', 'xlarge'] instead")
241
+
242
+ ### DRAWING FUNCTIONS ###
243
+
244
+ def scale_bar(ax, draw=True, style: Literal["ticks","boxes"]="boxes",
245
+ location: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]="upper right",
246
+ bar: None | bool | sbt._TYPE_BAR=None,
247
+ units: None | bool | sbt._TYPE_UNITS=None,
248
+ labels: None | bool | sbt._TYPE_LABELS=None,
249
+ text: None | bool | sbt._TYPE_TEXT=None,
250
+ aob: None | bool | sbt._TYPE_AOB=None,
251
+ return_aob: bool=True
252
+ ):
253
+
254
+ ##### VALIDATION #####
255
+ _style = sbf._validate(sbt._VALIDATE_PRIMARY, "style", style)
256
+ _location = sbf._validate(sbt._VALIDATE_PRIMARY, "location", location)
257
+
258
+ # This works the same as it does with the ScaleBar object(s)
259
+ # If a dictionary is passed to any of the elements, first validate that it is "correct"
260
+ # Note that we also merge the provided dict with the default style dict, so no keys are missing
261
+ # If a specific component is not desired, it should be set to False in the function call
262
+ if _style == "boxes":
263
+ _bar = sbf._validate_dict(bar, _del_keys(_DEFAULT_BAR, ["minor_frac","tick_loc","basecolors","tickcolors","tickwidth"]),
264
+ _del_keys(sbt._VALIDATE_BAR, ["minor_frac","tick_loc","basecolors","tickcolors","tickwidth"]), return_clean=True)
265
+ else:
266
+ _bar = sbf._validate_dict(bar, _del_keys(_DEFAULT_BAR, ["facecolors","edgecolors","edgewidth"]),
267
+ _del_keys(sbt._VALIDATE_BAR, ["facecolors","edgecolors","edgewidth"]), return_clean=True)
268
+ _units = sbf._validate_dict(units, _DEFAULT_UNITS, sbt._VALIDATE_UNITS, return_clean=True)
269
+ _labels = sbf._validate_dict(labels, _DEFAULT_LABELS, sbt._VALIDATE_LABELS, return_clean=True)
270
+ _text = sbf._validate_dict(text, copy.deepcopy(_DEFAULT_TEXT), sbt._VALIDATE_TEXT, return_clean=True) # this one has to be a deepcopy due to dictionary immutability
271
+ _aob = sbf._validate_dict(aob, _DEFAULT_AOB, sbt._VALIDATE_AOB, return_clean=True)
272
+
273
+ ##### CONFIGURING TEXT #####
274
+ # First need to convert each string font size (if any) to a point size
275
+ for d in [_text, _labels, _units]:
276
+ if d is not None and "fontsize" in d.keys():
277
+ d["fontsize"] = _convert_font_size(d["fontsize"])
278
+
279
+ # The text dictionary acts as a shortcut for setting text properties elsewhere (units, major, and minor)
280
+ # So the first order of business is to use it as an override for the other dictionaries as needed
281
+ _units = _text | _units
282
+ # Then change the textcolor key to textcolors so it fits in the major category
283
+ _text["textcolors"] = _text["textcolor"] # if we hadn't made a deepcopy above, this would cause errors later (text shouldn't have textcolors as a key)
284
+ _labels = _del_keys(_text, ["textcolor"]) | _labels
285
+
286
+ ##### CONFIG #####
287
+
288
+ # Getting the config for the bar (length, text, divs, etc.)
289
+ bar_max, bar_length, units_label, major_div, minor_div = _config_bar(ax, _bar)
290
+
291
+ # Getting the config for the segments (width, label, etc.)
292
+ segments = _config_seg(bar_max, bar_length/major_div, major_div, minor_div,
293
+ _bar["minor_type"], _labels["style"],
294
+ _labels["labels"], _labels["format"], _labels["format_int"])
295
+
296
+ # Also setting the segment gap to be either the edgewidth or the tickwidth
297
+ gap_width = _bar.get("edgewidth", _bar.get("edgewidth", 0))
298
+
299
+ # Overwriting the units label, if needed
300
+ if _units["label"] is not None:
301
+ units_label = _units["label"]
302
+
303
+ # Creating a temporary figure and axis for rendering later
304
+ fig_temp, ax_temp = _temp_figure(ax)
305
+
306
+ ##### BAR CONSTRUCTION #####
307
+
308
+ # Defining the height and width of the bar
309
+ # We call this "major" because we use the major divisions as the base for other calculations
310
+ major_width = (bar_length/major_div)*72
311
+ major_height = _bar["height"]*72
312
+
313
+ # Initializing the bar with an invisible spacer, to align it with the text later
314
+ # if all of these conditions are met, we do not need a spcer
315
+ if _labels["style"] == "last_only" and major_div==1 and (minor_div==1 or minor_div==0):
316
+ bar_artists = []
317
+ # If we have minor divs, it will be equal to half the minor width
318
+ elif minor_div > 1:
319
+ bar_artists = [_make_boxes(major_width/minor_div/2, major_height, "none", "none", gap_width)]
320
+ # if we don't, it will be equal to half the major width
321
+ else:
322
+ bar_artists = [_make_boxes(major_width/2, major_height, "none", "none", gap_width)]
323
+
324
+ # If we are making a bar with boxes:
325
+ if _style == "boxes":
326
+ # First increasing the length of the number of facecolors and edgecolors to match the number of segments
327
+ bar_facecolors = _expand_list(_bar["facecolors"], (major_div*minor_div), how="cycle")
328
+ # These one has to be +1, because we have one more label (or tick) than divs (since labels (and ticks) are aligned on breaks)
329
+ bar_edgecolors = _expand_list(_bar["edgecolors"], (major_div*minor_div)+1, how="cycle")
330
+
331
+ # Getting the widths of the boxes from our segments
332
+ # Note we drop the last segment - that is purely for the max value of the bar, which we don't represent with a segment, but does have a label
333
+ box_widths = [s["width"] for s in segments[:-1] if s["type"]!="spacer"]
334
+ # error checking the edge case when labels_style=="last_only" and major_div==1 and minor_div==0
335
+ if len(box_widths)==0:
336
+ box_widths = [segments[0]["width"]]
337
+ # Now we actually make all the division boxes
338
+ bar_artists += [_make_boxes(w, major_height, f, e, _bar["edgewidth"]) for w,f,e in zip(box_widths, bar_facecolors, bar_edgecolors)]
339
+
340
+ # If instead we are making it with ticks:
341
+ elif _style == "ticks":
342
+ # First increasing the length of the number of facecolors and edgecolors to match the number of segments
343
+ bar_basecolors = _expand_list(_bar["basecolors"], (major_div*minor_div), how="cycle")
344
+ # These one has to be +1, because we have one more label (or tick) than divs (since labels (and ticks) are aligned on breaks)
345
+ bar_tickcolors = _expand_list(_bar["tickcolors"], (major_div*minor_div)+1, how="cycle")
346
+
347
+ # Uniquely, we need the height of the minor ticks here
348
+ minor_height = major_height * _bar["minor_frac"]
349
+ # Then appending the fully-built bar of ticks
350
+ bar_artists += [_make_ticks(fig_temp, segments, _bar["tick_loc"], bar_max, bar_length,
351
+ major_width, major_height, minor_height,
352
+ bar_basecolors, bar_tickcolors, _bar["tickwidth"])]
353
+
354
+ # If the units text is supposed to be aligned with the bar, then we add it to the end of the list in its own container
355
+ if _units["loc"] == "bar":
356
+ # If we're reversing everything, need the text to appear on the other side
357
+ if _bar["reverse"] == True:
358
+ units_ha = "right"
359
+ units_x = major_width - _units["fontsize"]/2
360
+ else:
361
+ units_ha = "left"
362
+ units_x = _units["fontsize"]/2
363
+ bar_artists.append(_make_text(major_width, None, "none", "none", gap_width,
364
+ units_label, units_x, None, "center_baseline", units_ha, _units["textcolor"], _units["fontsize"],
365
+ _units["rotation"], _units["rotation_mode"], _units["stroke_width"], _units["stroke_color"],
366
+ **{k:v for k,v in _units.items() if k in ["fontfamily","fontstyle","fontweight"]}))
367
+
368
+ # If needed, reverse the order of the boxes
369
+ if _bar["reverse"] == True:
370
+ bar_artists.reverse()
371
+
372
+ # Now creating an HPacker for the box objects with zero padding and zero separation
373
+ bar_pack = matplotlib.offsetbox.HPacker(children=bar_artists, pad=0, sep=0, align="center")
374
+
375
+ ##### LABEL CONSTRUCTION #####
376
+ # If the user wants the units label to appear alongside the text, we will append it here
377
+ if _units["loc"] == "text":
378
+ if segments[-1]["label"] is not None:
379
+ segments[-1]["label"] += f" {units_label}"
380
+ else:
381
+ segments[-1]["label"] = units_label
382
+
383
+ # other text inputs
384
+ label_textcolors = _expand_list(_labels["textcolors"], (major_div*minor_div)+1, how="cycle")
385
+ label_height = _labels["fontsize"] + _labels["stroke_width"]*2
386
+ label_y = _labels["stroke_width"]
387
+ if _labels["style"] == "last_only":
388
+ if major_div==1 and (minor_div==1 or minor_div==0):
389
+ label_ha = "center"
390
+ elif _bar["reverse"] == True:
391
+ label_ha = "left"
392
+ else:
393
+ label_ha = "right"
394
+ else:
395
+ label_ha = "center"
396
+
397
+ # Creating the label artists
398
+ label_artists = []
399
+ # We already established the widths with the _config_seg function
400
+ label_artists += [(_make_text(l["length"], label_height, "none", "none", gap_width,
401
+ l["label"], l["length"]/2, label_y, "bottom", label_ha, c, _labels["fontsize"],
402
+ _labels["rotation"], _labels["rotation_mode"], _labels["stroke_width"], _labels["stroke_color"],
403
+ **{k:v for k,v in _labels.items() if k in ["fontfamily","fontstyle","fontweight"]}))
404
+ for l,c in zip(segments, label_textcolors)]
405
+
406
+ # If needed, reverse the order of the text
407
+ if _bar["reverse"] == True:
408
+ label_artists.reverse()
409
+ # Also need to change how we will align the VPacker
410
+ pack_align = "right"
411
+ else:
412
+ pack_align = "left"
413
+
414
+ # Now creating an HPacker for the text objects with zero padding and zero separation
415
+ label_pack = matplotlib.offsetbox.HPacker(children=label_artists, pad=0, sep=0)
416
+
417
+ ##### COMBINING #####
418
+
419
+ # Storing the boxes and the text
420
+ major_elements = [bar_pack, label_pack]
421
+
422
+ # Reversing the order if we want the text above the bar
423
+ if _labels["loc"]=="above":
424
+ major_elements.reverse()
425
+
426
+ # Vertically stacking the elements
427
+ major_pack = matplotlib.offsetbox.VPacker(children=major_elements, sep=_labels["sep"], pad=_labels["pad"], align=pack_align)
428
+
429
+ ##### UNITS (OPPOSITE ONLY) #####
430
+ # This is only relevant if loc for units is set to opposite!
431
+ # Which puts them on the opposite side of the text units
432
+ if _units["loc"]=="opposite":
433
+ if _bar["reverse"]==True:
434
+ units_x = major_width/2
435
+ units_ha = "left"
436
+ units_align = "left"
437
+ else:
438
+ units_x = major_width
439
+ units_ha = "right"
440
+ units_align = "right"
441
+ # Making the units text
442
+ units = _make_text(major_width*1.5, None, "none", "none", gap_width,
443
+ units_label, units_x, None, "center_baseline", units_ha, _units["textcolor"], _units["fontsize"],
444
+ _units["rotation"], _units["rotation_mode"], _units["stroke_width"], _units["stroke_color"],
445
+ **{k:v for k,v in _units.items() if k in ["fontfamily","fontstyle","fontweight"]})
446
+ # Stacking with the other elements
447
+ units_elements = [units, major_pack]
448
+
449
+ if _labels["loc"]=="above":
450
+ units_elements.reverse()
451
+
452
+ major_pack = matplotlib.offsetbox.VPacker(children=units_elements, sep=_units["sep"], pad=_units["pad"], align=units_align)
453
+
454
+ ##### RENDERING #####
455
+ # Here, we have to render the scale bar as an image on the temporary fig and ax we made
456
+ # This is because it is honestly too difficult to keep the image as-is and apply our rotations
457
+ # Mainly because Matplotlib doesn't let you place a nested OffsetBox inside of an AuxTransformBox with a rotation applied
458
+
459
+ # AOB will contain the final artist
460
+ aob_box = matplotlib.offsetbox.AnchoredOffsetbox(loc="center", child=major_pack, frameon=False, pad=0, borderpad=0)
461
+
462
+ # Function that will handle invisibly rendering our object, returning an image
463
+ img_scale_bar = _render_as_image(fig_temp, ax_temp, aob_box, _bar["rotation"])
464
+
465
+ ##### FINAL RENDER #####
466
+
467
+ # Placing the image in an OffsetBox, while rotating if desired
468
+ # We have to set the zoom level to be relative to the DPI as well (image is in pixels)
469
+ offset_img = matplotlib.offsetbox.OffsetImage(img_scale_bar, origin="upper", zoom=72/fig_temp.dpi)
470
+ # If desired, we can just return the rendered image in the final OffsetImage
471
+ # This will override any aob or draw selections! Only the OffsetImage is returned!
472
+ if return_aob==False:
473
+ if aob is not None:
474
+ warnings.warn(f"return_aob is set to False, but aob is not None: the settings for aob will be ignored, and an OffsetImage will instead be provided, which can be placed in an AnchoredOffsetBox of your choosing.")
475
+ if draw==True:
476
+ warnings.warn(f"return_aob is set to False, but draw is set to True: an OffsetImage of the ScaleBar will be returned by not drawn on the axis, which can be placed in an AnchoredOffsetBox of your choosing.")
477
+ return offset_img
478
+
479
+ # Then the offset image is placed in an AnchoredOffsetBox
480
+ aob_img = matplotlib.offsetbox.AnchoredOffsetbox(loc=_location, child=offset_img, **_del_keys(_aob, ["facecolor","edgecolor","alpha"]))
481
+ # Also setting the facecolor and transparency of the box
482
+ if _aob["facecolor"] is not None:
483
+ aob_img.patch.set_facecolor(_aob["facecolor"])
484
+ aob_img.patch.set_visible(True)
485
+ if _aob["edgecolor"] is not None:
486
+ aob_img.patch.set_edgecolor(_aob["edgecolor"])
487
+ aob_img.patch.set_visible(True)
488
+ if _aob["alpha"]:
489
+ aob_img.patch.set_alpha(_aob["alpha"])
490
+ aob_img.patch.set_visible(True)
491
+
492
+ # Finally, adding to the axis
493
+ if draw == True:
494
+ _ = ax.add_artist(aob_img)
495
+ # If not, we'll return the aob_im as an artist object (the ScaleBar draw() functions use this)
496
+ else:
497
+ return aob_img
498
+
499
+ # This is a convenience function for creating two scale bars, with different units, aligned with each other
500
+ # The bars should be identical except for the units and the divisions
501
+ # NOTE: still under development, will tidy up if there is usage of it
502
+ def dual_bars(ax, draw=True, style: Literal["ticks","boxes"]="boxes",
503
+ location: Literal["upper right", "upper left", "lower left", "lower right", "center left", "center right", "lower center", "upper center", "center"]="upper right",
504
+ units_dual=["mi","km"], bar_maxes=[None,None], bar_lengths=[None,None], major_divs=[None, None], minor_divs=[None, None],
505
+ bar: None | bool | sbt._TYPE_BAR=None,
506
+ units: None | bool | sbt._TYPE_UNITS=None,
507
+ labels: None | bool | sbt._TYPE_LABELS=None,
508
+ text: None | bool | sbt._TYPE_TEXT=None,
509
+ aob: None | bool | sbt._TYPE_AOB=None,
510
+ pad=0, sep=0,
511
+ return_aob: bool=True
512
+ ):
513
+
514
+ ##### VALIDATION #####
515
+ if type(units_dual) not in [list, tuple] or len(units_dual) != 2:
516
+ raise ValueError("units_dual must be a list or tuple of length 2")
517
+ if type(bar_maxes) not in [list, tuple] or len(bar_maxes) != 2:
518
+ raise ValueError("bar_maxes must be a list or tuple of length 2")
519
+ if type(bar_lengths) not in [list, tuple] or len(bar_lengths) != 2:
520
+ raise ValueError("bar_lengths must be a list or tuple of length 2")
521
+ if type(major_divs) not in [list, tuple] or len(major_divs) != 2:
522
+ raise ValueError("major_divs must be a list or tuple of length 2")
523
+ if type(minor_divs) not in [list, tuple] or len(minor_divs) != 2:
524
+ raise ValueError("minor_divs must be a list or tuple of length 2")
525
+
526
+ if units.get("loc", None) == "opposite":
527
+ raise ValueError("units['loc'] for units cannot be opposite for dual_bars, as it will not align correctly with the second scale bar")
528
+
529
+ if bar.get("rotation", None) is not None and bar.get("rotation", 0) != 0:
530
+ warnings.warn("bar['rotation'] is not fully support. It is recommended instead that you set rotation to zero and return the image by setting draw=False and return_aob=False, to return the OffsetImage of the dual scale bars instead.")
531
+ if bar.get("unit", None) is not None:
532
+ warnings.warn("bar['unit'] is ignored for dual_bars, as it is set by units_dual")
533
+ _ = bar.pop("unit")
534
+ if bar.get("max", None) is not None:
535
+ warnings.warn("bar['max'] is ignored for dual_bars, as it is (optionally) set by bar_maxes")
536
+ _ = bar.pop("max")
537
+ if bar.get("length", None) is not None:
538
+ warnings.warn("bar['length'] is ignored for dual_bars, as it is (optionally) set by bar_lengths")
539
+ _ = bar.pop("length")
540
+ if bar.get("major_div", None) is not None:
541
+ warnings.warn("bar['major_div'] is ignored for dual_bars, as it is (optionally) set by major_divs")
542
+ _ = bar.pop("major_div")
543
+ if bar.get("minor_div", None) is not None:
544
+ warnings.warn("bar['minor_div'] is ignored for dual_bars, as it is (optionally) set by minor_divs")
545
+ _ = bar.pop("minor_div")
546
+
547
+ _style = sbf._validate(sbt._VALIDATE_PRIMARY, "style", style)
548
+ _location = sbf._validate(sbt._VALIDATE_PRIMARY, "location", location)
549
+ _aob = sbf._validate_dict(aob, _DEFAULT_AOB, sbt._VALIDATE_AOB, return_clean=True)
550
+
551
+ ##### CREATION #####
552
+ # Setting up the order of some other settings (label location, tick location)
553
+ labels_loc = ["above","below"]
554
+ tick_loc = ["above","below"]
555
+ # Creating each bar in turn
556
+ bars = []
557
+ for unit,max,length,major_div,minor_div,label_loc,tick_loc in zip(units_dual, bar_maxes, bar_lengths, major_divs, minor_divs, labels_loc, tick_loc):
558
+ # Making the settings for each possible selection
559
+ bar_settings = {"unit":unit, "max":max, "length":length, "major_div":major_div, "minor_div":minor_div}
560
+ if _style == "ticks":
561
+ bar_settings["tick_loc"] = tick_loc
562
+ label_settings = {"loc":label_loc}
563
+ # Creating a bar
564
+ # Because draw is False and return_aob is false, an OffsetImage will be returned
565
+ bars.append(scale_bar(ax, draw=False, style=_style, location=location,
566
+ bar=((bar | bar_settings) if bar is not None else bar_settings),
567
+ units=units,
568
+ labels=(labels | label_settings if labels is not None else label_settings),
569
+ text=text,
570
+ return_aob=False))
571
+
572
+ ##### PACKING #####
573
+ # First need to know if we pack vertically or horizontally
574
+ bar_vertical = _calc_vert(bar["rotation"])
575
+ packer = matplotlib.offsetbox.VPacker if bar_vertical == False else matplotlib.offsetbox.HPacker
576
+ if bar["reverse"] == True:
577
+ align = "right" if bar_vertical == False else "top"
578
+ else:
579
+ align = "left" if bar_vertical == False else "bottom"
580
+
581
+ # Packing the bars together, with a separator between them
582
+ # The separator is a fixed size, and is not scaled with the bars - it represents the space between them
583
+ pack = packer(children=bars, align=align, pad=pad, sep=sep)
584
+
585
+ ##### NUDGING #####
586
+ # Placing the packer in the AOB first off
587
+ aob_pack = matplotlib.offsetbox.AnchoredOffsetbox(loc=_location, child=pack, **_del_keys(_aob, ["facecolor","edgecolor","alpha"]))
588
+ # Finding if and how much we need to nudge either image
589
+ aob_pack = _align_dual(ax, aob_pack, bar_vertical, bar["reverse"])
590
+
591
+ ##### FINAL RENDER #####
592
+ # If desired, we can just return the final packer
593
+ # This will override any aob or draw selections! Only the Packer is returned!
594
+ if return_aob==False:
595
+ if aob is not None:
596
+ warnings.warn(f"return_aob is set to False, but aob is not None: the settings for aob will be ignored, and a Packer will instead be provided, which can be placed in an AnchoredOffsetBox of your choosing.")
597
+ if draw==True:
598
+ warnings.warn(f"return_aob is set to False, but draw is set to True: the settings for draw will be ignored, and a Packer will instead be provided, which can be placed in an AnchoredOffsetBox of your choosing.")
599
+ return pack
600
+
601
+ # Also setting the facecolor and transparency of the box
602
+ if _aob["facecolor"] is not None:
603
+ aob_pack.patch.set_facecolor(_aob["facecolor"])
604
+ aob_pack.patch.set_visible(True)
605
+ if _aob["edgecolor"] is not None:
606
+ aob_pack.patch.set_edgecolor(_aob["edgecolor"])
607
+ aob_pack.patch.set_visible(True)
608
+ if _aob["alpha"]:
609
+ aob_pack.patch.set_alpha(_aob["alpha"])
610
+ aob_pack.patch.set_visible(True)
611
+
612
+ # Finally, adding to the axis
613
+ if draw == True:
614
+ _ = ax.add_artist(aob_pack)
615
+ # If not, we'll return the aob_im as an artist object (the ScaleBar draw() functions use this)
616
+ else:
617
+ return aob_pack
618
+
619
+ ### OTHER FUNCTIONS ###
620
+
621
+ # This function will remove any keys we specify from a dictionary
622
+ # This is useful if we need to unpack on certain values from a dictionary, and is used in scale_bar()
623
+ def _del_keys(dict, to_remove):
624
+ return {key: val for key, val in dict.items() if key not in to_remove}
625
+
626
+ # This function handles the config steps (width, divs, etc)
627
+ # that are shared across all the different scale bars
628
+ def _config_bar(ax, bar):
629
+
630
+ ## PLOT INFO ##
631
+ # Literally just getting the figure for the passed axis
632
+
633
+ fig = ax.get_figure()
634
+
635
+ ## ROTATION ##
636
+ # Calculating if the rotation is vertical or horizontal
637
+
638
+ bar_vertical = _calc_vert(bar["rotation"])
639
+
640
+ ## BAR DIMENSIONS ##
641
+ # Finding the max length and optimal divisions of the scale bar
642
+
643
+ # Finding the dimensions of the axis and the limits
644
+ # get_tightbbox() returns values in pixel coordinates
645
+ # so dividing by dpi gets us the inches of the axis
646
+ # Vertical scale bars are oriented against the y-axis (height)
647
+ if bar_vertical==True:
648
+ ax_dim = ax.patch.get_tightbbox().height / fig.dpi
649
+ min_lim, max_lim = ax.get_ylim()
650
+ # Horizontal scale bars are oriented against the x-axis (width)
651
+ else:
652
+ ax_dim = ax.patch.get_tightbbox().width / fig.dpi
653
+ min_lim, max_lim = ax.get_xlim()
654
+ # This calculates the range from max to min on the axis of interest
655
+ ax_range = abs(max_lim - min_lim)
656
+
657
+ ## UNITS ##
658
+ # Now, calculating the proportion of the dimension axis that we need
659
+
660
+ # Capturing the unit from the projection
661
+ # (We use bar_vertical to index; 0 is for east-west axis, 1 is for north-south)
662
+ units_proj = pyproj.CRS(bar["projection"]).axis_info[bar_vertical].unit_name
663
+ # If the provided units are in degrees, we will convert to meters first
664
+ # This will recalculate the ax_range
665
+ if units_proj=="degree":
666
+ warnings.warn(f"Provided CRS {bar["projection"]} uses degrees. An attempt will be made at conversion, but there will be accuracy issues: it is recommended that you use a projected CRS instead.")
667
+ ylim = ax.get_ylim()
668
+ xlim = ax.get_xlim()
669
+ # Using https://github.com/seangrogan/great_circle_calculator/blob/master/great_circle_calculator/great_circle_calculator.py
670
+ # If the bar is vertical, we use the midpoint of the longitude (x-axis) and the max and min of the latitude (y-axis)
671
+ if bar_vertical==True:
672
+ ax_range = distance_between_points(((xlim[0]+xlim[1])/2, ylim[0]), ((xlim[0]+xlim[1])/2, ylim[1]))
673
+ # Otherwise, the opposite
674
+ else:
675
+ ax_range = distance_between_points((xlim[0], (ylim[0]+ylim[1])/2), (xlim[1], (ylim[0]+ylim[1])/2))
676
+ # Setting units_proj to meters now
677
+ units_proj = "m"
678
+
679
+ # If a projected CRS is provided instead...
680
+ else:
681
+ # Standardizing the projection unit
682
+ try:
683
+ units_proj = sbt.units_standard[units_proj]
684
+ except:
685
+ warnings.warn(f"Units for specified projection ({units_proj}) are considered invalid; please use a different projection that conforms to an expected unit value (such as US survey feet or metres)")
686
+ return None
687
+
688
+ # Standardizing the units specified by the user
689
+ # This means we will also handle conversion if necessary
690
+ try:
691
+ units_user = sbt.units_standard.get(bar["unit"])
692
+ except:
693
+ warnings.warn(f"Desired output units selected by user ({bar["unit"]}) are considered invalid; please use one of the units specified in the units_standard dictionary in defaults.py")
694
+ units_user = None
695
+
696
+ # Converting
697
+
698
+ # First, the case where the user doesn't provide any units
699
+ # In this instance, we just use the units from the projection
700
+ if units_user is None:
701
+ units_label = units_proj
702
+ # If necessary, scaling "small" units to "large" units
703
+ # Meters to km
704
+ if units_proj == "m" and ax_range > (1000*5):
705
+ ax_range = ax_range / 1000
706
+ units_label = "km"
707
+ # Feet to mi
708
+ elif units_proj == "ft" and ax_range > (5280*5):
709
+ ax_range = ax_range / 5280
710
+ units_label = "mi"
711
+
712
+ # Otherwise, if the user supplied a unit of some sort, then handle conversion
713
+ else:
714
+ units_label = units_user
715
+ # We only need to do so if the units are different, however!
716
+ if units_user != units_proj:
717
+ # This works by finding the ratios between the two units, using meters as the base
718
+ ax_range = ax_range * (sbt.convert_dict[units_proj] / sbt.convert_dict[units_user])
719
+
720
+ ## BAR LENGTH AND MAX VALUE ##
721
+ # bar_max is the length of the bar in UNITS, not INCHES
722
+ # If it is not provided, the optimal value is calculated
723
+ if bar["max"] is None:
724
+ # If no bar length is provided, set to ~25% of the limit
725
+ if bar["length"] is None:
726
+ bar_max = 0.25 * ax_range
727
+ # If the value is less than 1, set to that proportion of the limit
728
+ elif bar["length"] < 1:
729
+ bar_max = bar["length"] * ax_range
730
+ # Otherwise, assume the value is already in inches, and calculate the fraction relative to the axis
731
+ # Then find the proportion of the limit
732
+ else:
733
+ if bar["length"] < ax_dim:
734
+ bar_max = (bar["length"] / ax_dim) * ax_range
735
+ else:
736
+ warnings.warn(f"Provided bar length ({bar["length"]}) is greater than the axis length ({ax_dim}); setting bar length to default (20% of axis length).")
737
+ bar_max = 0.25 * ax_range
738
+ # If bar["max"] is provided, don't need to go through all of this effort
739
+ else:
740
+ if bar["length"] is not None:
741
+ warnings.warn("Both bar['max'] and bar['length'] were set, so the value for bar['length'] will be ignored. Please reference the documentation to understand why both may not be set at the same time.")
742
+ bar_max = bar["max"]
743
+
744
+
745
+ ## BAR DIVISIONS ##
746
+ # If both a max bar value and the # of breaks is provided, will not need to auto calculate
747
+ if bar["max"] is not None and bar["major_div"] is not None:
748
+ bar_max = bar["max"]
749
+ bar_length = (bar_max / ax_range) * ax_dim
750
+ major_div = bar["major_div"]
751
+ # If we don't want minor divs, 1 is the default value to auto-hide it
752
+ if bar.get("minor_type","none") == "none":
753
+ minor_div = 1
754
+ # Else, if the minor div is not provided, will generate a default
755
+ elif bar["minor_div"] is None:
756
+ # If major div is divisible by 2, then 2 is a good minor div
757
+ if major_div % 2 == 0:
758
+ minor_div = 2
759
+ # Otherwise, will basically auto-hide the minor div
760
+ else:
761
+ minor_div = 1
762
+ else:
763
+ minor_div = bar["minor_div"]
764
+
765
+ # If none, or only one, is provided, need to auto calculate optimal values
766
+ else:
767
+ # First, if a max bar value IS provided, but not the # of breaks, provide a warning that the value might be changed
768
+ if bar["max"] is not None or bar["major_div"] is not None:
769
+ warnings.warn(f"As one of bar['max'] and bar['major_div'] were not set, the values will be calculated automatically. This may result in different values from your input.")
770
+ # Finding the magnitude of the max of the bar
771
+ for units_mag in range(0,23):
772
+ if bar_max / (10 ** (units_mag+1)) > 1.5:
773
+ units_mag += 1
774
+ else:
775
+ break
776
+
777
+ # Calculating the RMS for each preferred max number we have
778
+ major_breaks = list(sbt.preferred_divs.keys())
779
+ major_rms = [math.sqrt((m - (bar_max/(10**units_mag)))**2) for m in major_breaks]
780
+
781
+ # Sorting for the "best" number
782
+ # Sorted() works on the first item in the tuple contained in the list
783
+ sorted_breaks = [(m,r) for r,m in sorted(zip(major_rms, major_breaks))]
784
+
785
+ # Saving the values
786
+ bar_max_best = sorted_breaks[0][0]
787
+ bar_max = bar_max_best * 10**units_mag
788
+ bar_length = (bar_max / ax_range) * ax_dim
789
+ major_div = sbt.preferred_divs[bar_max_best][0]
790
+ if bar.get("minor_type","none") == "none":
791
+ minor_div = 1
792
+ else:
793
+ minor_div = sbt.preferred_divs[bar_max_best][1]
794
+
795
+ return bar_max, bar_length, units_label, major_div, minor_div
796
+
797
+ # This function handles the creation of the segments and their labels
798
+ # It is a doozy - needs to handle all the different inputs for minor_type and label_type
799
+ # The output of this function will be a list of dictionaries
800
+ # With each element in the list representing a segment with four keys:
801
+ # width (for the segment, in points), length(for the label, in points), value (numeric value in units), type (major or minor or spacer), and label (either the value (rounded if needed) or None if no label is required)
802
+ def _config_seg(bar_max, major_width, major_div, minor_div, minor_type, label_style, labels, format_str, format_int):
803
+ segments = []
804
+ ## SEGMENT WIDTHS ##
805
+ # Starting with the minor boxes, if any
806
+ if minor_div > 1:
807
+ # If minor_type is first, we only need to append minor boxes for the first set of major divisions
808
+ if minor_type == "first":
809
+ # Minor
810
+ segments += [{"width":(major_width/minor_div), "length":(major_width/minor_div), "value":(d*(bar_max/major_div/minor_div)), "type":"minor"} for d in range(0,minor_div)]
811
+ # The edge between minor and major needs to have the width of a major div, but the lenght if a minor one
812
+ segments += [{"width":(major_width), "length":(major_width/minor_div), "value":(bar_max/major_div), "type":"major"}]
813
+ # After this we need to add a spacer! Otherwise our major divisions are offset
814
+ # I figured out the ((minor_div-1)/2) part by trial and error, but it seems to work well enough for now
815
+ segments += [{"width":(major_width/minor_div*((minor_div-1)/2)), "length":(major_width/minor_div*((minor_div-1)/2)), "value":-1, "type":"spacer", "label":None}]
816
+ # All the major divs (if any) after this are normal
817
+ if major_div > 1:
818
+ segments += [{"width":(major_width),"length":(major_width), "value":(d*(bar_max/major_div)), "type":"major"} for d in range(2,major_div+1)]
819
+ # If minor_type is all, we append minor boxes for every major division, and no major boxes at all
820
+ else:
821
+ # Here, we have to do another correction for the minor divs that fall on what would be a major division
822
+ segments += [{"width":(major_width/minor_div), "length":(major_width/minor_div), "value":(d*(bar_max/(major_div*minor_div))), "type":"major"}
823
+ if ((d*(bar_max/(major_div*minor_div))) % (bar_max/major_div) == 0) else
824
+ {"width":(major_width/minor_div), "length":(major_width/minor_div), "value":(d*(bar_max/(major_div*minor_div))), "type":"minor"}
825
+ for d in range(0,(minor_div*major_div)+1)]
826
+ # If you don't have minor divs, you only make boxes for the major divs, and you start at the zeroeth position
827
+ else:
828
+ segments += [{"width":(major_width), "length":(major_width), "value":(d*(bar_max/major_div)), "type":"major"} for d in range(0,major_div+1)]
829
+
830
+ # For all segments, we make sure that the first and last types are set to major
831
+ segments[0]["type"] = "major"
832
+ segments[-1]["type"] = "major"
833
+
834
+ # Expanding all the widths
835
+ for s in segments:
836
+ s["width"] = s["width"]*72
837
+ s["length"] = s["length"]*72
838
+
839
+ ## LABELS ##
840
+ # If we only have major labels, we only need to add labels to the major segments
841
+ if label_style=="major":
842
+ for s in segments:
843
+ if s["type"] == "major":
844
+ s["label"] = s["value"]
845
+ else:
846
+ s["label"] = None
847
+ # If we only have the first and last labels
848
+ elif label_style=="first_last":
849
+ for i,s in enumerate(segments):
850
+ if i == 0 or i == len(segments)-1:
851
+ s["label"] = s["value"]
852
+ else:
853
+ s["label"] = None
854
+ # If we only have the last label
855
+ elif label_style=="last_only":
856
+ for i,s in enumerate(segments):
857
+ if i == len(segments)-1:
858
+ s["label"] = s["value"]
859
+ else:
860
+ s["label"] = None
861
+ # This is a custom override when specific conditions are met
862
+ if major_div==1 and (minor_div==1 or minor_div==0):
863
+ # We only need to keep the last segment
864
+ segments[0] = segments[1]
865
+ segments = segments[:1]
866
+ # If we only have labels on the first minor segment, plus all the major segments
867
+ elif label_style=="minor_first":
868
+ apply_minor = True
869
+ for i,s in enumerate(segments):
870
+ if s["type"] == "major":
871
+ s["label"] = s["value"]
872
+ elif s["type"] == "minor" and apply_minor == True:
873
+ s["label"] = s["value"]
874
+ apply_minor = False
875
+ else:
876
+ s["label"] = None
877
+ # If we have labels on all the minor segments, plus all the major segments
878
+ elif label_style=="minor_all":
879
+ for s in segments:
880
+ if s["type"] != "spacer":
881
+ s["label"] = s["value"]
882
+
883
+ ## CUSTOM LABELS ##
884
+ # If custom labels are passed, we will use this to simply overwrite the labels we have already calculated
885
+ # Note that we also check the correct number of labels are passed
886
+ if labels is not None:
887
+ num_labels = len([s for s in segments if s["label"] is not None])
888
+ if num_labels < len(labels):
889
+ warnings.warn(f"More labels were provided ({len(labels)}) than needed. Only the first {num_labels} will be used.")
890
+ elif num_labels > len(labels):
891
+ warnings.warn(f"Fewer labels were provided ({len(labels)}) than needed ({num_labels}). The last {num_labels-len(labels)} will be set to None.")
892
+ labels = _expand_list(labels, num_labels, "nfill")
893
+ # Keeping track of how many labels we have applied
894
+ i = 0
895
+ for s in segments:
896
+ if s["label"] is not None:
897
+ if labels[i] == True or type(labels[i])==int or type(labels[i])==float:
898
+ s["label"] = _format_numeric(s["label"], format_str, format_int)
899
+ pass
900
+ elif labels[i] == False or labels[i] is None:
901
+ s["label"] = None
902
+ else:
903
+ s["label"] = labels[i]
904
+ i += 1
905
+ else:
906
+ # If no custom labels are passed, we will clean up the ones we generated
907
+ for s in segments:
908
+ if s["label"] is None:
909
+ pass
910
+ else:
911
+ s["label"] = _format_numeric(s["label"], format_str, format_int)
912
+
913
+ # Returning everything at the end
914
+ return segments
915
+
916
+ # A small function for calculating the number of 90 degree rotations that are being applied
917
+ # So we know if the bar is in a vertical or a horizontal rotation
918
+ def _calc_vert(degrees):
919
+ # Figuring out how many quarter turns the rotation value is approximately
920
+ quarters = int(round(degrees/90,0))
921
+
922
+ # EVEN quarter turns (0, 180, 360, -180) are considered horizontal
923
+ # ODD quarter turns (90, 270, -90, -270) are considered vertical
924
+ if quarters % 2 == 0:
925
+ bar_vertical = False
926
+ else:
927
+ bar_vertical = True
928
+
929
+ return bar_vertical
930
+
931
+ # A small function for expanding a list a potentially uneven number of times
932
+ # Ex. ['black','white'] -> ['black','white','black','white''black']
933
+ def _expand_list(seq: list, length: int, how="cycle", convert=True):
934
+ if type(seq) != list and convert == True:
935
+ seq = [seq]
936
+ # Cycle through the shorter list and add items repetitively
937
+ if how == "cycle":
938
+ # To hold the expanded list
939
+ seq_expanded = []
940
+ # To reach the desired length
941
+ i = 0
942
+ # While loop to pick colors until the desired length is reached
943
+ while i < length:
944
+ # This cycles the selection
945
+ pick = i % len(seq)
946
+ # Appending the selected color to our expanded list
947
+ seq_expanded.append(seq[pick])
948
+ i += 1
949
+ # Fill the list with Nones
950
+ elif how == "nfill":
951
+ # Copying the shorter list
952
+ seq_expanded = seq.copy()
953
+ # Resetting the length
954
+ i = len(seq_expanded)
955
+ # Filling the rest of the list with nones
956
+ seq_expanded += [None for i in range(i, length)]
957
+ # Returning the longer list
958
+ return seq_expanded
959
+
960
+ # A function to convert font sizes that are passed as a string to points
961
+ def _convert_font_size(font_size):
962
+ if type(font_size) != str:
963
+ return font_size
964
+ else:
965
+ # Getting the current default font size
966
+ default_font_size = matplotlib.rcParams["font.size"]
967
+ # Creating the mapping of string size to multiplier
968
+ # See https://github.com/matplotlib/matplotlib/blob/v3.5.1/lib/matplotlib/font_manager.py#L51-L62
969
+ font_scalings = matplotlib.font_manager.font_scalings
970
+ # Confirming the string is in the mapping
971
+ if font_size in font_scalings.keys():
972
+ # Getting the multiplier
973
+ multiplier = font_scalings[font_size]
974
+ # Calculating the new size
975
+ size_points = default_font_size * multiplier
976
+ # Returning the new size
977
+ return size_points
978
+ # If the string is not in the mapping, return the default size, and raise a warnings
979
+ else:
980
+ warnings.warn(f"The string {font_size} is not a valid font size. Using the default font size of {default_font_size} points.")
981
+ return default_font_size
982
+
983
+ # A function to format a numeric string as we desire
984
+ def _format_numeric(val, fmt, integer_override=True):
985
+ # If the format is None, we return the string as is
986
+ if fmt is None or fmt == "":
987
+ return val
988
+ # If the format is a string, we return a function that formats the string as desired
989
+ else:
990
+ if integer_override == True and type(val) == int or (type(val) == float and val % 1 == 0):
991
+ return f"{int(val)}"
992
+ else:
993
+ return f"{val:{fmt}}"
994
+
995
+ # A small function for creating a temporary figure based on a provided axis
996
+ def _temp_figure(ax, axis=False, visible=False):
997
+ # Getting the figure of the provided axis
998
+ fig = ax.get_figure()
999
+ # Getting the dimensions of the axis
1000
+ ax_bbox = ax.patch.get_tightbbox()
1001
+ # Converting to inches and rounding up
1002
+ ax_dim = math.ceil(max(ax_bbox.height, ax_bbox.width) / fig.dpi)
1003
+ # Creating a new temporary figure
1004
+ fig_temp, ax_temp = matplotlib.pyplot.subplots(1,1, figsize=(ax_dim*1.5, ax_dim*1.5), dpi=fig.dpi)
1005
+ # Turning off the x and y labels if desired
1006
+ if axis == False:
1007
+ ax_temp.axis("off")
1008
+ # Turning off the backgrounds of the figure and axis
1009
+ if visible == False:
1010
+ fig_temp.patch.set_visible(False)
1011
+ ax_temp.patch.set_visible(False)
1012
+ # Returning
1013
+ return fig_temp, ax_temp
1014
+
1015
+ # A function to make a drawing area with a rectangle within it
1016
+ # This is used pretty frequently when making spacers and major boxes
1017
+ def _make_boxes(width, height, facecolor, edgecolor, linewidth):
1018
+ # First, a drawing area to store the rectangle, and a rectangle to color it
1019
+ area = matplotlib.offsetbox.DrawingArea(width=width, height=height, clip=False)
1020
+ rect = matplotlib.patches.Rectangle((0,0), width=width, height=height, facecolor=facecolor, edgecolor=edgecolor, linewidth=linewidth)
1021
+ # Placing the rect in the drawing area
1022
+ area.add_artist(rect)
1023
+ # Returning the final drawing area
1024
+ return area
1025
+
1026
+ # A function to make a drawing area with all of the ticks involved in it: major, minor, and the base tick that constitutes the bar
1027
+ # Unlike _make_boxes, we don't call this within the context of a for loop - it will go ahead and return a list
1028
+ def _make_ticks(fig, segments, tick_loc, bar_max, bar_length, major_width, major_height, minor_height, bar_facecolors, bar_edgecolors, edgewidth):
1029
+ # Converting everything back to inches
1030
+ major_width = major_width/72
1031
+ major_height = major_height/72
1032
+ minor_height = minor_height/72
1033
+ # Filtering out any spacers in our segment
1034
+ segments = [s for s in segments if s["type"] != "spacer"]
1035
+ # Creating a ATB to scale everything in inches
1036
+ bar_ticks = matplotlib.offsetbox.AuxTransformBox(fig.dpi_scale_trans)
1037
+ # Establishing where each vertical tick should span
1038
+ # Each of these will be relative to x=0, which is where the horizontal line will be drawn
1039
+ if tick_loc == "below":
1040
+ y_major = (-major_height,0)
1041
+ y_minor = (-(minor_height),0)
1042
+ elif tick_loc == "middle":
1043
+ y_major = (-(major_height/2), (major_height/2))
1044
+ y_minor = (-(minor_height/2), (minor_height/2))
1045
+ else:
1046
+ y_major = (0,major_height)
1047
+ y_minor = (0,minor_height)
1048
+
1049
+ # Now iterating through each segment and making the ticks
1050
+ # Note we split this in two: first doing the horizontal, then the vertical ticks
1051
+ # This is so they are drawn correctly (vertical on top of horizontal)
1052
+ # But also so that we can exclude the first (invisible) horizontal tick
1053
+
1054
+ # This will help us keep track of the width of the bars
1055
+ x_pos = 0
1056
+ for s,f in zip(segments[1:], bar_facecolors):
1057
+ width = s["value"]/bar_max*bar_length
1058
+ bar_ticks.add_artist((matplotlib.lines.Line2D([x_pos,width],[0,0], color=f, linewidth=edgewidth, solid_capstyle="butt")))
1059
+ # Updating the x position for the next tick
1060
+ x_pos = width
1061
+
1062
+ # Resetting the x_pos for the second iteration
1063
+ x_pos = 0
1064
+ for s,e in zip(segments, bar_edgecolors):
1065
+ x_pos = s["value"]/bar_max*bar_length
1066
+ if s["type"] == "major":
1067
+ bar_ticks.add_artist(matplotlib.lines.Line2D([x_pos,x_pos],y_major, color=e, linewidth=edgewidth, solid_capstyle="projecting"))
1068
+ elif s["type"] == "minor":
1069
+ bar_ticks.add_artist(matplotlib.lines.Line2D([x_pos,x_pos],y_minor, color=e, linewidth=edgewidth, solid_capstyle="projecting"))
1070
+ else:
1071
+ pass
1072
+
1073
+ # Returning the ATB of ticks
1074
+ return bar_ticks
1075
+
1076
+ # A function, similar to above, but for adding text as well
1077
+ # This is used pretty frequently when making spacers and major boxes
1078
+ def _make_text(width, height, facecolor, edgecolor, linewidth,
1079
+ text_label, text_x, text_y, text_va, text_ha, text_color, text_fontsize,
1080
+ text_rotation=0, text_mode="anchor", stroke_width=0, stroke_color="none", **kwargs):
1081
+ # First, creating a path effect for the stroke, if needed
1082
+ if stroke_width > 0:
1083
+ text_stroke = [matplotlib.patheffects.withStroke(linewidth=stroke_width, foreground=stroke_color)]
1084
+ else:
1085
+ text_stroke = []
1086
+ # Then, calculating the height (if one wasn't provided)
1087
+ if height is None:
1088
+ height = text_fontsize + stroke_width*2
1089
+ if text_y is None and text_va=="center_baseline":
1090
+ text_y = height/2
1091
+ # Then, the drawing area
1092
+ area = _make_boxes(width, height, facecolor, edgecolor, linewidth)
1093
+ # Checking that the text is not None
1094
+ if text_label is not None:
1095
+ # Placing the text in the drawing area
1096
+ text = matplotlib.text.Text(x=text_x, y=text_y, text=text_label, va=text_va, ha=text_ha, color=text_color, fontsize=text_fontsize,
1097
+ rotation=text_rotation, rotation_mode=text_mode, path_effects=text_stroke, **kwargs)
1098
+ area.add_artist(text)
1099
+ # Returning the final drawing area with text
1100
+ return area
1101
+
1102
+ # During testing, noticed some issues with odd/prime numbers of divs causing misalignment of the bars by ~1 pixel
1103
+ # So this is intended to check for alignment
1104
+ def _align_dual(ax, artist, bar_vertical, reverse):
1105
+ # First, making a temporary axis to render the image
1106
+ fig_temp, ax_temp = _temp_figure(ax=ax)
1107
+ # Then, rendering the image, and getting back a 2D array
1108
+ img_temp = _render_as_image(fig_temp, ax_temp, copy.deepcopy(artist), rotation=0, remove=True)
1109
+ # Getting the image as an array
1110
+ arr_temp = numpy.array(img_temp)
1111
+ # Now, comparing when the top and bottom bars (or left and right) start to each other
1112
+ # This is done by finding the midpoint of the image (should be symmetric), and then looking ~15 pixels up and down the image
1113
+ if bar_vertical == True:
1114
+ dim = 1
1115
+ else:
1116
+ dim = 0
1117
+ midpoint = arr_temp.shape[dim] // 2
1118
+ dim_top = int(midpoint + 15)
1119
+ dim_bot = int(midpoint - 15)
1120
+
1121
+ # Now, for each row at the specified height (or each column at the specified width for vertical artists),
1122
+ # find the first non-transparent pixel
1123
+ if bar_vertical == True:
1124
+ slice_top = arr_temp[:, dim_top, :].copy()
1125
+ slice_bot = arr_temp[:, dim_bot, :].copy()
1126
+ else:
1127
+ slice_top = arr_temp[dim_top, :, :].copy()
1128
+ slice_bot = arr_temp[dim_bot, :, :].copy()
1129
+ # We'll need to work backwards if reverse == True
1130
+ if reverse == True:
1131
+ slice_top = numpy.flip(slice_top, axis=dim)
1132
+ slice_bot = numpy.flip(slice_bot, axis=dim)
1133
+ # Now iterating through each slice to find the index of the first non-transparent pixel
1134
+ xtop = -1
1135
+ xbot = -1
1136
+ for it,pt in enumerate(slice_top):
1137
+ if pt[3] > 20:
1138
+ xtop = it
1139
+ break
1140
+ for ib,pb in enumerate(slice_bot):
1141
+ if pb[3] > 20:
1142
+ xbot = ib
1143
+ break
1144
+ # Finding the difference
1145
+ # If we weren't able to determine the right nudge amount, just return the original artist
1146
+ if xtop == -1 or xbot == -1 or xtop == xbot:
1147
+ return artist
1148
+ # Otherwise we are just calculating the difference in pixels,
1149
+ # Which is the number of blank rows/columns we need to add to align
1150
+ else:
1151
+ diff = xtop - xbot
1152
+
1153
+ # Now, adding a row/column of blank pixels to create the alignment, as needed
1154
+ # Getting the child artist we need
1155
+ if diff < 0:
1156
+ child = artist.get_child().get_children()[1]
1157
+ else:
1158
+ child = artist.get_child().get_children()[0]
1159
+ # Creating the dimension of blank row/columns
1160
+ if bar_vertical == True:
1161
+ to_append = numpy.array([[[255,0,0,0]]*child.get_data().shape[1]]*abs(diff))
1162
+ axis_append = 0
1163
+ else:
1164
+ to_append = numpy.array([[[255,0,0,0]]*abs(diff)]*child.get_data().shape[0])
1165
+ axis_append = 1
1166
+ # Appending the blank row/column to the child artist
1167
+ if reverse == True:
1168
+ child.set_data(numpy.concatenate((child.get_data(), to_append), axis=axis_append))
1169
+ else:
1170
+ child.set_data(numpy.concatenate((to_append, child.get_data()), axis=axis_append))
1171
+ # Returning the new 2-bar artist
1172
+ return artist
1173
+
1174
+ # A function that handles invisibly rendering an artist and returning its image
1175
+ def _render_as_image(fig, ax, artist, rotation, add=True, remove=True, close=True):
1176
+ # If needed, adding the artist to the axis
1177
+ if add == True:
1178
+ ax.add_artist(artist)
1179
+ # Draw the figure, but without showing it, to place all the elements
1180
+ fig.draw_without_rendering()
1181
+ # Sets the canvas for the figure to AGG (Anti-Grain Geometry)
1182
+ canvas = FigureCanvasAgg(fig)
1183
+ # Draws the figure onto the canvas
1184
+ canvas.draw()
1185
+ # Converts the rendered figure to an RGBA(lpha) array
1186
+ rgba = numpy.asarray(canvas.buffer_rgba())
1187
+ # Converts the array to a PIL image object
1188
+ img = PIL.Image.fromarray(rgba)
1189
+ # Rotates the image
1190
+ img = img.rotate(rotation, expand=True)
1191
+ # Crops the transparent pixels out of the image
1192
+ img = img.crop(img.getbbox())
1193
+ # If needed, removing the artist from the axis
1194
+ if remove == True:
1195
+ artist.remove()
1196
+ # If needed, closing the figure, to ensure it doesn't render
1197
+ if close == True:
1198
+ matplotlib.pyplot.close(fig)
1199
+ # Returning the image
1200
+ return img