diff --git a/doc/bar_style_geography.png b/doc/bar_style_geography.png new file mode 100644 index 0000000..d7c03a6 Binary files /dev/null and b/doc/bar_style_geography.png differ diff --git a/doc/bar_style_geography.py b/doc/bar_style_geography.py new file mode 100644 index 0000000..5ac9338 --- /dev/null +++ b/doc/bar_style_geography.py @@ -0,0 +1,34 @@ +import matplotlib.pyplot as plt +from matplotlib_scalebar.scalebar import ScaleBar +from osmnx import features_from_place +from geopandas import GeoSeries +from shapely import Point + +fig, ax = plt.subplots() +ax.xaxis.set_visible(False) +ax.yaxis.set_visible(False) +ax.set_facecolor("ivory") +ax.set_xlim(58.12, 58.20) +ax.set_ylim(54.90, 54.95) + +place = "Ust-Katav" +water_gdf = features_from_place(place, tags={"natural": "water"}) +forest_gdf = features_from_place(place, {"natural": ["wood", "tree", "tree_row"]}) +landuse_gdf = features_from_place(place, tags={"landuse": True}) +highway_gdf = features_from_place(place, tags={"highway": ["secondary", "tertiary"]}) + +forest_gdf.plot(ax=ax, facecolor="palegreen") +landuse_gdf.plot(ax=ax, facecolor="lightgray", edgecolor="gray") +water_gdf.plot(ax=ax, facecolor="lightskyblue", edgecolor="deepskyblue") +highway_gdf.plot(ax=ax, color="palegoldenrod", linewidth=3) + +points = GeoSeries([Point(58.12, 54.90), Point(59.12, 54.90)], crs=4326).to_crs(32619) + +scalebar = ScaleBar( + dx=points[0].distance(points[1]), + bar_style="geography", + length_fraction=1, + width_fraction=0.03, +) +ax.add_artist(scalebar) +fig.savefig("bar_style_geography.png", dpi=60, bbox_inches="tight") diff --git a/matplotlib_scalebar/scalebar.py b/matplotlib_scalebar/scalebar.py index efd5a5d..4e7b973 100644 --- a/matplotlib_scalebar/scalebar.py +++ b/matplotlib_scalebar/scalebar.py @@ -23,6 +23,7 @@ - scalebar.box_alpha - scalebar.scale_loc - scalebar.label_loc + - scalebar.bar_style See the class documentation (:class:`.Scalebar`) for a description of the parameters. @@ -40,6 +41,7 @@ # Standard library modules. import bisect import warnings +import math import dataclasses # Third party modules. @@ -61,6 +63,7 @@ AnchoredOffsetbox, ) from matplotlib.patches import Rectangle +from matplotlib.colors import to_rgba # Local modules. from matplotlib_scalebar.dimension import ( @@ -89,6 +92,9 @@ _VALID_ROTATIONS = ["horizontal", "horizontal-only", "vertical", "vertical-only"] _validate_rotation = ValidateInStrings("rotation", _VALID_ROTATIONS, ignorecase=True) +_VALID_SCALE_STYLES = ["simple", "geography"] +_validate_bar_style = ValidateInStrings("bar_style", _VALID_SCALE_STYLES, ignorecase=True) + def _validate_legend_loc(loc): rc = matplotlib.RcParams() @@ -111,6 +117,7 @@ def _validate_legend_loc(loc): "scalebar.scale_loc": ["bottom", _validate_scale_loc], "scalebar.label_loc": ["top", _validate_label_loc], "scalebar.rotation": ["horizontal", _validate_rotation], + "scalebar.bar_style": ["simple", _validate_bar_style], } ) @@ -190,6 +197,7 @@ def __init__( box_alpha=None, scale_loc=None, label_loc=None, + bar_style=None, font_properties=None, label_formatter=None, scale_formatter=None, @@ -291,6 +299,10 @@ def __init__( If ``none`` the label is not shown. :type label_loc: :class:`str` + :arg bar_style: style of box + (default: rcParams['scalebar.bar_style'] or ``simple``) + :type bar_style: :class:`str` + :arg font_properties: font properties of the label text, specified either as dict or `fontconfig `_ pattern (XML). @@ -370,6 +382,7 @@ def __init__( self.box_alpha = box_alpha self.scale_loc = scale_loc self.label_loc = label_loc + self.bar_style = bar_style self.scale_formatter = scale_formatter self.font_properties = font_properties self.fixed_value = fixed_value @@ -403,6 +416,76 @@ def _calculate_exact_length(self, value, units): newvalue = self.dimension.convert(value, units, self.units) return newvalue / self.dx + def _draw_simple_rect(self, rotation, length_px, width_px, color): + if rotation == "horizontal": + rec = Rectangle( + (0, 0), + length_px, + width_px, + fill=True, + facecolor=color, + edgecolor="none", + ) + else: + rec = Rectangle( + (0, 0), + width_px, + length_px, + fill=True, + facecolor=color, + edgecolor="none", + ) + return [rec] + + def _draw_geography_rect( + self, + rotation, + length_px, + width_px, + color, + value, + ): + def calc_best_step(length): + capacity = int(math.floor(math.log10(length))) + step = math.pow(10, capacity) + if step * 2 > length: + step = step / 2 + return step + + step = calc_best_step(value) + step_px = step * length_px / value + + current_color = color + second_color = "white" + + filled_px = 0 + rectangles = [] + while filled_px < length_px: + if filled_px + step_px <= length_px: + rec_step_px = step_px + else: + rec_step_px = length_px - filled_px + if rotation == "horizontal": + rec_xy = (filled_px, 0) + rec_width = rec_step_px + rec_height = width_px + else: + rec_xy = (0, filled_px) + rec_width = width_px + rec_height = rec_step_px + rectangle = Rectangle( + rec_xy, + rec_width, + rec_height, + fill=True, + facecolor=current_color, + edgecolor=color + ) + rectangles.append(rectangle) + current_color, second_color = second_color, current_color + filled_px += rec_step_px + return rectangles + def draw(self, renderer, *args, **kwargs): self._info = None @@ -446,6 +529,7 @@ def _get_value(attr, default): box_alpha = _get_value("box_alpha", 1.0) scale_loc = _get_value("scale_loc", "bottom").lower() label_loc = _get_value("label_loc", "top").lower() + bar_style = _get_value("bar_style", "simple") font_properties = self.font_properties fixed_value = self.fixed_value fixed_units = self.fixed_units or self.units @@ -488,28 +572,20 @@ def _get_value(attr, default): width_px = abs(ylim[1] - ylim[0]) * width_fraction - # Create scale bar - if rotation == "horizontal": - scale_rect = Rectangle( - (0, 0), + if not bar_style or bar_style == "simple": + scale_rects = self._draw_simple_rect(rotation, length_px, width_px, color) + elif bar_style == "geography": + scale_rects = self._draw_geography_rect( + rotation, length_px, width_px, - fill=True, - facecolor=color, - edgecolor="none", - ) - else: - scale_rect = Rectangle( - (0, 0), - width_px, - length_px, - fill=True, - facecolor=color, - edgecolor="none", + color, + value, ) scale_bar_box = AuxTransformBox(ax.transData) - scale_bar_box.add_artist(scale_rect) + for rect in scale_rects: + scale_bar_box.add_artist(rect) # Create scale text if scale_loc != "none": @@ -796,6 +872,19 @@ def set_scale_formatter(self, scale_formatter): scale_formatter = property(get_scale_formatter, set_scale_formatter) + def get_bar_style(self): + return self._bar_style + + def set_bar_style(self, bar_style): + if bar_style is not None and bar_style not in _VALID_SCALE_STYLES: + raise ValueError( + f"Unknown bar_style: {bar_style}. " + f"Valid bar styles: {', '.join(_VALID_SCALE_STYLES)}" + ) + self._bar_style = bar_style + + bar_style = property(get_bar_style, set_bar_style) + def get_label_formatter(self): warnings.warn( "The get_label_formatter method is deprecated. " diff --git a/tests/test_scalebar.py b/tests/test_scalebar.py index 1093296..0b1e28c 100644 --- a/tests/test_scalebar.py +++ b/tests/test_scalebar.py @@ -59,6 +59,7 @@ def test_mpl_rcParams_update(): "scalebar.scale_loc": "bottom", "scalebar.label_loc": "top", "scalebar.rotation": "horizontal", + "scalebar.bar_style": "simple", } matplotlib.rcParams.update(params) @@ -371,6 +372,16 @@ def test_bbox_transform(scalebar): assert scalebar.bbox_transform == scalebar.axes.transAxes +@pytest.mark.parametrize("bar_style", ["simple", "geography"]) +def test_bar_style(scalebar, bar_style): + assert scalebar.get_bar_style() is None + assert scalebar.bar_style is None + + scalebar.set_bar_style(bar_style) + assert scalebar.get_bar_style() == bar_style + assert scalebar.bar_style == bar_style + + def test_info(): fig = plt.figure() ax = fig.add_subplot(111)