Skip to content

Furniture — Scale Bar & North Arrow#

The cleopatra.styling.furniture module draws the two remaining pieces of standard chart furniture cleopatra did not have: a scale bar and a north arrow. Both are free functions that decorate an existing matplotlib.axes.Axes and return the frameless inset axes they drew on, exactly like stamp_mark — so they read alike and anchor identically (they share stamp_mark's corner-placement plumbing) and use the same box / label_location / label_size vocabulary as ColorBar.

from cleopatra.styling.furniture import add_scale_bar, add_north_arrow

The key design point is the package boundary: these are plain matplotlib artistry and know nothing about geography. A scale bar is length axes data units wide with a caller-supplied label string; a north arrow is rotated by a caller-supplied rotation in degrees. The ellipsoidal questions — how long is 100 km in axis units at this latitude in this projection, what is the grid convergence here — belong to whoever owns the CRS (the consumer), never to this generic layer. So a micrograph with a µm bar, a floor plan, an engineering section and a map all use the identical artist. There is no pyproj import here and no CRS logic of any kind.

Both draw the bar / arrow on a frameless inset axes in axes-fraction coordinates, so the furniture stays anchored in its corner across a dpi or limits change rather than drifting like a data-coordinate Rectangle, and sits at a high zorder above the data.

Scale bar#

import matplotlib.pyplot as plt
import numpy as np
from cleopatra.styling.furniture import add_scale_bar, ScaleBar

fig, ax = plt.subplots()
ax.imshow(np.random.default_rng(0).random((100, 100)), extent=[0, 500_000, 0, 500_000])

# the consumer computes the ground distance in axis units; cleopatra draws it
add_scale_bar(ax, 100_000, ScaleBar(label="100 km", location="lower left", segments=4, box=True))

length is in the axes' x data units; all the presentation options are grouped into a ScaleBar object (mirroring FacetLayout / ColorBar), so the call stays small. label is the caption you supply (defaulting to f"{length:g}"); segments sets the number of alternating blocks (1 draws a plain bar); ticks=True (default) numbers the block boundaries 0 .. length, a sequence numbers those data positions, and False draws no numbers. location is one of the four corners; pad, height and the two colours are axes-fraction / matplotlib values; label_location ("bottom" / "top" / None for the interior side) picks the caption side; box adds a backing panel (True, a colour, or a dict of Rectangle kwargs).

North arrow#

from cleopatra.styling.furniture import add_north_arrow, NorthArrow

# rotation (grid convergence) is the caller's to supply — cleopatra never derives it
add_north_arrow(ax, 0.0, NorthArrow(location="upper right", style="arrow"))

rotation is degrees clockwise from up (e.g. the grid convergence at the map centre) and stays a direct argument (like length on the scale bar); the arrow and its "N" label rotate together. The presentation options are grouped into a NorthArrow object: style is "arrow" (a single filled arrow), "needle" (a two-tone compass needle) or "rose" (a four-point compass star); size, location, pad, label, the colours and box mirror ScaleBar.

GeoMixin sugar#

The six geographic glyphs (ArrayGlyph, MeshGlyph, VectorGlyph, FlowGlyph, PolygonGlyph, ScatterGlyph) expose thin methods next to add_tiles / add_features / add_labels, so you can decorate a glyph without importing the free functions or repeating the axes:

glyph.plot()
glyph.add_scale_bar(100_000, ScaleBar(label="100 km", location="lower left"))
glyph.add_north_arrow(grid_convergence_deg, NorthArrow(style="needle"))

The free functions stay the API; the methods only supply the glyph's axes.

cleopatra.styling.furniture.ScaleBar dataclass #

Presentation options for add_scale_bar (everything but the axes/length).

Grouped into one object -- mirroring FacetLayout / ColorBar -- so the scale-bar call stays small: add_scale_bar(ax, length, ScaleBar(...)).

Attributes:

Name Type Description
label str | None

The caption under (or over) the bar, e.g. "100 km". Defaults to f"{length:g}" when None.

location str

Which corner to anchor to -- one of "lower right", "lower left", "upper right", "upper left".

pad float | tuple[float, float]

The gap between the bar and the axes edges, as an axes fraction -- a scalar for both axes or an (x, y) pair, each in [0, 1).

height float

The bar thickness as an axes fraction.

segments int

The number of alternating blocks. 1 draws a plain bar. Must be >= 1.

ticks bool | Sequence[float]

True numbers the segments + 1 block boundaries 0 .. length; a sequence places tick numbers at those data positions (each in [0, length]); False draws no tick numbers.

color str

The fill of the even blocks and the colour of the block outline, the ticks and the text.

edge_color str

The fill of the odd blocks (the alternating light blocks).

label_location str | None

"top" or "bottom" -- which side of the bar the tick numbers and caption sit on. None picks the side facing the axes interior for the chosen corner (a lower corner labels above the bar, an upper corner below), so the caption never spills off the edge.

label_size float | None

Font size (points) for the tick numbers and caption. None uses matplotlib's default.

box bool | str | dict | None

A backing panel behind the bar, using ColorBar's vocabulary -- None / False (none), True (translucent white), a colour string, or a dict of Rectangle kwargs.

zorder float | None

The draw order for the furniture. None uses a high default that sits above the data.

Source code in src/cleopatra/styling/furniture.py
@dataclass(frozen=True)
class ScaleBar:
    """Presentation options for `add_scale_bar` (everything but the axes/length).

    Grouped into one object -- mirroring `FacetLayout` / `ColorBar` -- so the
    scale-bar call stays small: `add_scale_bar(ax, length, ScaleBar(...))`.

    Attributes:
        label: The caption under (or over) the bar, e.g. `"100 km"`. Defaults
            to `f"{length:g}"` when `None`.
        location: Which corner to anchor to -- one of `"lower right"`,
            `"lower left"`, `"upper right"`, `"upper left"`.
        pad: The gap between the bar and the axes edges, as an axes fraction --
            a scalar for both axes or an `(x, y)` pair, each in `[0, 1)`.
        height: The bar thickness as an axes fraction.
        segments: The number of alternating blocks. `1` draws a plain bar.
            Must be `>= 1`.
        ticks: `True` numbers the `segments + 1` block boundaries `0 .. length`;
            a sequence places tick numbers at those data positions (each in
            `[0, length]`); `False` draws no tick numbers.
        color: The fill of the even blocks and the colour of the block outline,
            the ticks and the text.
        edge_color: The fill of the odd blocks (the alternating light blocks).
        label_location: `"top"` or `"bottom"` -- which side of the bar the tick
            numbers and caption sit on. `None` picks the side facing the axes
            interior for the chosen corner (a lower corner labels above the bar,
            an upper corner below), so the caption never spills off the edge.
        label_size: Font size (points) for the tick numbers and caption.
            `None` uses matplotlib's default.
        box: A backing panel behind the bar, using `ColorBar`'s vocabulary --
            `None` / `False` (none), `True` (translucent white), a colour
            string, or a dict of `Rectangle` kwargs.
        zorder: The draw order for the furniture. `None` uses a high default
            that sits above the data.
    """

    label: str | None = None
    location: str = "lower right"
    pad: float | tuple[float, float] = 0.025
    height: float = 0.012
    segments: int = 2
    ticks: bool | Sequence[float] = True
    color: str = "black"
    edge_color: str = "white"
    label_location: str | None = None
    label_size: float | None = None
    box: bool | str | dict | None = None
    zorder: float | None = None

cleopatra.styling.furniture.add_scale_bar(ax, length, spec=None) #

Draw a segmented scale bar on ax, sized in the axes' own data units.

The bar is length data units wide (the caller computes that number -- cleopatra owns no geodesy), rendered as spec.segments alternating blocks on a frameless inset axes anchored in one corner. Tick numbers at the block boundaries and the caption are drawn on the parent axes in axes-fraction coordinates, so the whole assembly stays put across a dpi or limits change instead of drifting like a data-coordinate Rectangle.

Parameters:

Name Type Description Default
ax Axes

The axes to decorate. Its current x-limits set the data-to-figure scale, so call this after the data is plotted and the limits are final.

required
length float

The bar length in the axes' x data units. Must be finite and > 0.

required
spec ScaleBar | None

The presentation options as a ScaleBar (label, location, pad, segments, colours, box, ...). None uses all defaults.

None

Returns:

Name Type Description
Axes Axes

The frameless inset axes the bar was drawn on, so the caller can

Axes

adjust it further.

Raises:

Type Description
ValueError

If spec.location is not a corner, length is not finite and positive, spec.pad is out of range, spec.segments < 1, spec.label_location is not "top" / "bottom", the axes has a zero-width x-range, or pad + bar width leaves no room on the axes.

Examples:

  • A four-block "100 km" bar in the lower-left corner:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.styling.furniture import add_scale_bar, ScaleBar
    >>> fig, ax = plt.subplots()
    >>> ax.set_xlim(0, 500_000)
    (0.0, 500000.0)
    >>> bar = add_scale_bar(
    ...     ax, 100_000, ScaleBar(label="100 km", location="lower left", segments=4)
    ... )
    >>> len(bar.patches)   # four alternating blocks
    4
    >>> plt.close(fig)
    
Source code in src/cleopatra/styling/furniture.py
def add_scale_bar(ax: Axes, length: float, spec: ScaleBar | None = None) -> Axes:
    """Draw a segmented scale bar on `ax`, sized in the axes' own data units.

    The bar is `length` **data units** wide (the caller computes that number --
    cleopatra owns no geodesy), rendered as `spec.segments` alternating blocks
    on a frameless inset axes anchored in one corner. Tick numbers at the block
    boundaries and the caption are drawn on the parent axes in axes-fraction
    coordinates, so the whole assembly stays put across a dpi or limits change
    instead of drifting like a data-coordinate `Rectangle`.

    Args:
        ax: The axes to decorate. Its current x-limits set the data-to-figure
            scale, so call this after the data is plotted and the limits are
            final.
        length: The bar length in the axes' **x data units**. Must be finite
            and `> 0`.
        spec: The presentation options as a `ScaleBar` (label, location, pad,
            segments, colours, box, ...). `None` uses all defaults.

    Returns:
        Axes: The frameless inset axes the bar was drawn on, so the caller can
        adjust it further.

    Raises:
        ValueError: If `spec.location` is not a corner, `length` is not finite
            and positive, `spec.pad` is out of range, `spec.segments < 1`,
            `spec.label_location` is not `"top"` / `"bottom"`, the axes has a
            zero-width x-range, or `pad + bar width` leaves no room on the axes.

    Examples:
        - A four-block "100 km" bar in the lower-left corner:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.styling.furniture import add_scale_bar, ScaleBar
            >>> fig, ax = plt.subplots()
            >>> ax.set_xlim(0, 500_000)
            (0.0, 500000.0)
            >>> bar = add_scale_bar(
            ...     ax, 100_000, ScaleBar(label="100 km", location="lower left", segments=4)
            ... )
            >>> len(bar.patches)   # four alternating blocks
            4
            >>> plt.close(fig)

            ```
    """
    spec = spec or ScaleBar()
    label = spec.label
    location = spec.location
    pad = spec.pad
    height = spec.height
    segments = spec.segments
    ticks = spec.ticks
    color = spec.color
    edge_color = spec.edge_color
    label_location = spec.label_location
    label_size = spec.label_size
    box = spec.box
    zorder = spec.zorder

    if location not in _CORNERS:
        raise ValueError(f"location must be one of {list(_CORNERS)}, got {location!r}.")
    if not np.isfinite(length) or length <= 0.0:
        raise ValueError(f"length must be a finite positive number, got {length!r}.")
    if segments < 1:
        raise ValueError(f"segments must be >= 1, got {segments!r}.")
    if label_location not in ("top", "bottom", None):
        raise ValueError(
            f"label_location must be 'top', 'bottom', or None, got {label_location!r}."
        )
    if label_location is None:
        # Default: grow the tick numbers / caption toward the axes interior, so a
        # bar in a lower corner labels above the bar and one in an upper corner
        # labels below -- otherwise a default lower-corner bar spills its caption
        # off the bottom edge, over the axis tick labels.
        label_location = "top" if location.startswith("lower") else "bottom"
    pad_x, pad_y = _as_margins(pad)

    x0d, x1d = ax.get_xlim()
    data_range = abs(float(x1d) - float(x0d))
    if not data_range:
        raise ValueError("the axes has a zero-width x-range; cannot size a scale bar.")
    width = length / data_range
    if pad_x + width > 1.0:
        raise ValueError(
            f"the bar spans {width:.3g} of the axes width; with pad_x={pad_x} it does "
            "not fit. Shorten `length`, zoom out, or reduce `pad`."
        )

    z = _FURNITURE_ZORDER if zorder is None else zorder
    x0, y0 = _corner_origin(location, width, height, pad_x, pad_y)

    at_top = label_location == "top"
    # The tick marks / numbers / caption grow away from the bar on the label
    # side. `sign` points from the bar toward that side in axes fraction.
    sign = 1.0 if at_top else -1.0
    bar_edge = (y0 + height) if at_top else y0
    text_va = "bottom" if at_top else "top"

    _draw_scale_box(ax, x0, y0, width, height, sign, bar_edge, bool(ticks), box, z)
    inset = _draw_bar_segments(
        ax, x0, y0, width, height, segments, color, edge_color, z
    )
    _label_scale_bar(
        ax,
        x0,
        width,
        bar_edge,
        sign,
        text_va,
        ticks,
        length,
        segments,
        label,
        color,
        label_size,
        z,
    )
    return inset

cleopatra.styling.furniture.NorthArrow dataclass #

Presentation options for add_north_arrow (everything but the axes/rotation).

Grouped into one object -- mirroring ScaleBar / FacetLayout / ColorBar -- so the call stays small: add_north_arrow(ax, rotation, NorthArrow(...)).

Attributes:

Name Type Description
location str

Which corner to anchor to -- one of "upper right" (default), "upper left", "lower right", "lower left".

pad float | tuple[float, float]

The gap to the axes edges, as an axes fraction -- a scalar or an (x, y) pair, each in [0, 1).

size float

The arrow height as an axes fraction.

style str

"arrow" (a single filled arrow), "needle" (a two-tone compass diamond), or "rose" (a four-point compass star). The needle and rose split each spike into a color flank and an edge_color flank.

label str | None

The label at the arrow tip. Defaults to "N"; None draws none.

color str

The primary fill and the outline / label colour.

edge_color str

The secondary (alternating) flank fill of a needle / rose.

label_size float | None

Font size (points) for the label. None uses the default.

box bool | str | dict | None

A backing panel, using ColorBar's box vocabulary (see ScaleBar).

zorder float | None

The draw order. None uses a high default above the data.

Source code in src/cleopatra/styling/furniture.py
@dataclass(frozen=True)
class NorthArrow:
    """Presentation options for `add_north_arrow` (everything but the axes/rotation).

    Grouped into one object -- mirroring `ScaleBar` / `FacetLayout` / `ColorBar` --
    so the call stays small: `add_north_arrow(ax, rotation, NorthArrow(...))`.

    Attributes:
        location: Which corner to anchor to -- one of `"upper right"` (default),
            `"upper left"`, `"lower right"`, `"lower left"`.
        pad: The gap to the axes edges, as an axes fraction -- a scalar or an
            `(x, y)` pair, each in `[0, 1)`.
        size: The arrow height as an axes fraction.
        style: `"arrow"` (a single filled arrow), `"needle"` (a two-tone
            compass diamond), or `"rose"` (a four-point compass star). The
            needle and rose split each spike into a `color` flank and an
            `edge_color` flank.
        label: The label at the arrow tip. Defaults to `"N"`; `None` draws none.
        color: The primary fill and the outline / label colour.
        edge_color: The secondary (alternating) flank fill of a needle / rose.
        label_size: Font size (points) for the label. `None` uses the default.
        box: A backing panel, using `ColorBar`'s `box` vocabulary (see
            `ScaleBar`).
        zorder: The draw order. `None` uses a high default above the data.
    """

    location: str = "upper right"
    pad: float | tuple[float, float] = 0.025
    size: float = 0.06
    style: str = "arrow"
    label: str | None = "N"
    color: str = "black"
    edge_color: str = "white"
    label_size: float | None = None
    box: bool | str | dict | None = None
    zorder: float | None = None

cleopatra.styling.furniture.add_north_arrow(ax, rotation=0.0, spec=None) #

Draw a north arrow on ax, rotated by a caller-supplied angle.

The arrow is drawn undistorted on a frameless inset axes anchored in one corner and rotated rotation degrees clockwise from straight up (the caller supplies the grid convergence -- cleopatra owns no CRS). The "N" label rotates with it.

Parameters:

Name Type Description Default
ax Axes

The axes to decorate.

required
rotation float

Degrees clockwise from up to rotate the arrow (e.g. the grid convergence at the map centre). Must be finite. Defaults to 0.

0.0
spec NorthArrow | None

The presentation options as a NorthArrow (location, size, style, label, colours, box, ...). None uses all defaults.

None

Returns:

Name Type Description
Axes Axes

The frameless inset axes the arrow was drawn on.

Raises:

Type Description
ValueError

If spec.location is not a corner, spec.style is unknown, rotation is not finite, spec.pad is out of range, or pad + size leaves no room on the axes.

Examples:

  • A plain north arrow in the upper-right corner:
    >>> import matplotlib
    >>> matplotlib.use("Agg")
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.styling.furniture import add_north_arrow
    >>> fig, ax = plt.subplots()
    >>> arrow = add_north_arrow(ax, rotation=0.0)
    >>> len(arrow.patches)   # one filled arrow
    1
    >>> plt.close(fig)
    
Source code in src/cleopatra/styling/furniture.py
def add_north_arrow(
    ax: Axes, rotation: float = 0.0, spec: NorthArrow | None = None
) -> Axes:
    """Draw a north arrow on `ax`, rotated by a caller-supplied angle.

    The arrow is drawn undistorted on a frameless inset axes anchored in one
    corner and rotated `rotation` degrees clockwise from straight up (the caller
    supplies the grid convergence -- cleopatra owns no CRS). The `"N"` label
    rotates with it.

    Args:
        ax: The axes to decorate.
        rotation: Degrees clockwise from up to rotate the arrow (e.g. the grid
            convergence at the map centre). Must be finite. Defaults to `0`.
        spec: The presentation options as a `NorthArrow` (location, size, style,
            label, colours, box, ...). `None` uses all defaults.

    Returns:
        Axes: The frameless inset axes the arrow was drawn on.

    Raises:
        ValueError: If `spec.location` is not a corner, `spec.style` is unknown,
            `rotation` is not finite, `spec.pad` is out of range, or
            `pad + size` leaves no room on the axes.

    Examples:
        - A plain north arrow in the upper-right corner:
            ```python
            >>> import matplotlib
            >>> matplotlib.use("Agg")
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.styling.furniture import add_north_arrow
            >>> fig, ax = plt.subplots()
            >>> arrow = add_north_arrow(ax, rotation=0.0)
            >>> len(arrow.patches)   # one filled arrow
            1
            >>> plt.close(fig)

            ```
    """
    spec = spec or NorthArrow()
    location = spec.location
    pad = spec.pad
    size = spec.size
    style = spec.style
    label = spec.label
    color = spec.color
    edge_color = spec.edge_color
    label_size = spec.label_size
    box = spec.box
    zorder = spec.zorder

    if location not in _CORNERS:
        raise ValueError(f"location must be one of {list(_CORNERS)}, got {location!r}.")
    if style not in _NORTH_STYLES:
        raise ValueError(f"style must be one of {list(_NORTH_STYLES)}, got {style!r}.")
    if not np.isfinite(rotation):
        raise ValueError(f"rotation must be a finite number, got {rotation!r}.")
    pad_x, pad_y = _as_margins(pad)

    # Keep the arrow square on screen: scale the inset's axes-fraction width by
    # the axes' inch aspect so a unit box maps to a square, and a rotation in
    # that box is a true screen rotation.
    fig_w_in, fig_h_in = ax.figure.get_size_inches()
    ax_pos = ax.get_position()
    ax_w_in = max(ax_pos.width * fig_w_in, 1e-9)
    ax_h_in = max(ax_pos.height * fig_h_in, 1e-9)
    width = size * (ax_h_in / ax_w_in)
    if pad_x + width > 1.0 or pad_y + size > 1.0:
        raise ValueError(
            f"pad + arrow size leaves no room on the axes (needs {width:.3g} x "
            f"{size:.3g} in axes fraction). Reduce `size` or `pad`."
        )

    z = _FURNITURE_ZORDER if zorder is None else zorder
    x0, y0 = _corner_origin(location, width, size, pad_x, pad_y)

    _draw_box(ax, x0, y0, width, size, box, z)

    inset = ax.inset_axes((x0, y0, width, size), transform=ax.transAxes, zorder=z + 0.1)
    inset.set_xlim(0.0, 1.0)
    inset.set_ylim(0.0, 1.0)
    inset.set_navigate(False)
    inset.set_in_layout(False)
    inset.axis("off")
    inset.patch.set_visible(False)

    rot = Affine2D().rotate_deg_around(0.5, 0.5, -rotation) + inset.transData
    for patch in _north_arrow_patches(style, color, edge_color):
        patch.set_transform(rot)
        inset.add_patch(patch)

    if label is not None:
        inset.text(
            0.5,
            0.99,
            label,
            ha="center",
            va="bottom",
            color=color,
            fontsize=label_size,
            fontweight="bold",
            rotation=-rotation,
            rotation_mode="anchor",
            transform=rot,
            clip_on=False,
        )
    return inset