matplotlib-map-utils 1.0.3__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.
- matplotlib_map_utils/__init__.py +5 -1
- matplotlib_map_utils/core/__init__.py +4 -0
- matplotlib_map_utils/{north_arrow.py → core/north_arrow.py} +56 -55
- matplotlib_map_utils/core/scale_bar.py +1200 -0
- matplotlib_map_utils/defaults/__init__.py +4 -0
- matplotlib_map_utils/{defaults.py → defaults/north_arrow.py} +4 -4
- matplotlib_map_utils/defaults/scale_bar.py +377 -0
- matplotlib_map_utils/validation/__init__.py +2 -0
- matplotlib_map_utils/validation/functions.py +231 -0
- matplotlib_map_utils/validation/north_arrow.py +175 -0
- matplotlib_map_utils/validation/scale_bar.py +274 -0
- matplotlib_map_utils-2.0.0.dist-info/METADATA +281 -0
- matplotlib_map_utils-2.0.0.dist-info/RECORD +18 -0
- {matplotlib_map_utils-1.0.3.dist-info → matplotlib_map_utils-2.0.0.dist-info}/WHEEL +1 -1
- matplotlib_map_utils/validation.py +0 -332
- matplotlib_map_utils-1.0.3.dist-info/METADATA +0 -131
- matplotlib_map_utils-1.0.3.dist-info/RECORD +0 -11
- {matplotlib_map_utils-1.0.3.dist-info → matplotlib_map_utils-2.0.0.dist-info}/LICENSE +0 -0
- {matplotlib_map_utils-1.0.3.dist-info → matplotlib_map_utils-2.0.0.dist-info}/top_level.txt +0 -0
@@ -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
|