From 90669e764e42b398908a2afa9b350e7ef534a06d Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 21:36:37 +0200 Subject: [PATCH 1/7] Give every lane value its own color past the wheel The string palette cycled WHEEL_ORDER, so the nineteenth distinct value reused the first one's color. An inventory path naming twenty-one values drew three pairs identically, and a legend whose swatch means two things is worse than no legend at all. Past what the wheel holds, values now walk the hue circle by the golden ratio instead, held near tab20's saturation so the two schemes sit together in a figure whose other lanes still take the wheel. --- dascore/viz/_lanes.py | 25 ++++++++++++++++++++++ tests/test_viz/test_lanes.py | 41 +++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 666e980c0..bb87abd4f 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -9,6 +9,7 @@ from __future__ import annotations +import colorsys import datetime from collections.abc import Mapping, Sequence @@ -36,6 +37,13 @@ NUMERIC_CMAP = "viridis" UNCOVERED_COLOR = "0.7" +# The wheel holds every color tab20 offers which is not a grey, so a +# palette past it can only repeat itself. Values then walk the hue circle: +# stepping by the golden ratio keeps neighbors in the sorted order apart, +# and alternating shade separates two which still land on a similar hue. +_GOLDEN_STEP = 0.6180339887498949 + + # The fraction of the x axis hatched where a bar runs off the end of it. _OPEN_FRACTION = 0.02 _MAX_SUB_ROWS = 8 @@ -162,6 +170,19 @@ def _pack_rows(frame) -> np.ndarray: return np.minimum(rows, _MAX_SUB_ROWS - 1) +def _wide_colors(values) -> dict: + """One distinct color per value, past what the wheel can hold.""" + out = {} + for index, value in enumerate(values): + hue = (index * _GOLDEN_STEP) % 1.0 + pale = index % 2 + # Held near tab20's own saturation so the two schemes sit together + # in a figure whose other lanes are still colored from the wheel. + rgb = colorsys.hsv_to_rgb(hue, 0.38 if pale else 0.62, 0.92 if pale else 0.72) + out[value] = (*rgb, 1.0) + return out + + def _string_colors(frame, vocabulary=None, cmap_name=STRING_CMAP) -> dict: """Map every string value to a stable color. @@ -170,6 +191,10 @@ def _string_colors(frame, vocabulary=None, cmap_name=STRING_CMAP) -> dict: """ seen = list(frame["value"].tolist()) + list(vocabulary or []) values = sorted({x for x in seen if isinstance(x, str) and x != ""}) + if len(values) > len(WHEEL_ORDER): + # Cycling the wheel here would give two values one color, and a + # legend which says one swatch means two things is worse than none. + return _wide_colors(values) cmap = plt.get_cmap(cmap_name) return { value: cmap(WHEEL_ORDER[index % len(WHEEL_ORDER)]) diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 8305c6a88..13d0b9ed3 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -12,7 +12,12 @@ from matplotlib.collections import PatchCollection from dascore.exceptions import ParameterError -from dascore.viz._lanes import UNCOVERED_COLOR, _pack_rows, plot_lanes +from dascore.viz._lanes import ( + UNCOVERED_COLOR, + WHEEL_ORDER, + _pack_rows, + plot_lanes, +) def _collections(ax): @@ -440,6 +445,40 @@ def test_vocabulary_widens_the_palette(self, string_frame): _collections(shifted)[0].get_facecolors()[0], ) + def test_no_two_values_share_a_color(self): + """Past what the wheel holds a palette must widen, not repeat. + + A legend whose swatch means two things is worse than none. + """ + names = [f"value {x:02d}" for x in range(len(WHEEL_ORDER) + 8)] + frame = pd.DataFrame( + { + "start": np.arange(float(len(names))), + "end": np.arange(float(len(names))) + 1.0, + "v": names, + } + ) + ax = plot_lanes(frame, value="v") + colors = {tuple(x) for x in _collections(ax)[0].get_facecolors()} + assert len(colors) == len(names) + + def test_a_wide_palette_is_still_stable(self): + """A value keeps its color whether or not the others are drawn.""" + names = [f"value {x:02d}" for x in range(len(WHEEL_ORDER) + 8)] + frame = pd.DataFrame({"start": [0.0], "end": [1.0], "v": [names[0]]}) + alone = plot_lanes(frame, value="v", vocabulary=names) + first = _collections(alone)[0].get_facecolors()[0] + plt.close("all") + whole = pd.DataFrame( + { + "start": np.arange(float(len(names))), + "end": np.arange(float(len(names))) + 1.0, + "v": names, + } + ) + together = plot_lanes(whole, value="v") + assert np.allclose(first, _collections(together)[0].get_facecolors()[0]) + def test_labels_decided_the_same_at_any_dpi(self): """Whether a label fits is a question about the figure, not its dpi.""" frame = pd.DataFrame( From 518936c8e21255212c53493c4165eb440b60f39d Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 21:37:10 +0200 Subject: [PATCH 2/7] Fit lane labels to the figure they land in, and the legend with them Three things kept a dense lane figure from reading. The labels were measured before the legend was drawn. Constrained layout then reserved that legend's width, and the axes lost close to a quarter of its own, so every label was judged against a box wider than the one it landed in and the survivors overlapped their neighbors. The fit now runs last, once the legend and any colorbars have taken their room, and a label must clear its box by a few pixels rather than merely equal it. Measuring correctly on its own left almost nothing drawn: three labels of eighty-five on the inventory path this came from, since the boxes are metres wide on an axis of kilometres. A label too wide for its box is now turned on its side and kept when it fits that way, which is most of them; only what fits neither way falls through to the legend. The legend itself was one column anchored beside the axes, and a figure naming more values than its axes is tall ran off the bottom of the page. Such a legend is laid out in columns below the lanes instead, and path() counts the rows it will take so the lanes do not give up the room. --- dascore/viz/_lanes.py | 143 +++++++++++++++++++++------ dascore/viz/inventory.py | 18 +++- tests/test_viz/test_inventory_viz.py | 55 +++++++++++ tests/test_viz/test_lanes.py | 93 +++++++++++++++++ 4 files changed, 276 insertions(+), 33 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index bb87abd4f..76dcab89d 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -43,6 +43,10 @@ # and alternating shade separates two which still land on a similar hue. _GOLDEN_STEP = 0.6180339887498949 +# Pixels of clearance a label needs inside its box. Without it a label the +# exact width of its box touches the one in the next box, and the two read +# as one word. +_LABEL_PAD = 4.0 # The fraction of the x axis hatched where a bar runs off the end of it. _OPEN_FRACTION = 0.02 @@ -316,37 +320,79 @@ def _draw_open_edges(ax, rows, y_low, height, colors, span): ax.add_collection(patches) +def _box_pixels(transform, x_mid, y_mid, width, height): + """The size of one box, in pixels, however the axes is scaled.""" + low = transform.transform((x_mid - width / 2, y_mid - height / 2)) + high = transform.transform((x_mid + width / 2, y_mid + height / 2)) + return abs(high[0] - low[0]), abs(high[1] - low[1]) + + def _fit_labels(ax, placements, max_labels): - """Draw the labels which fit in their box, and drop the rest.""" + """Draw each label the way it fits its box, and drop what cannot. + + Horizontal reads best, so it is tried first. A lane of many short + stretches gives every box far less width than its text needs, and + turning the text on its side fits it where the one rule would drop + it and leave the lane readable only from the legend. + """ if len(placements) > max_labels: return figure = ax.get_figure() # Lay the figure out before measuring: a label is compared against its - # box in pixels, and both move when the axes does. + # box in pixels, and both move when the axes does. The legend and the + # colorbars are drawn by now, so this is the geometry it lands in. figure.draw_without_rendering() renderer = figure.canvas.get_renderer() transform = ax.transData - for text, x_mid, y_mid, width in placements: + for text, x_mid, y_mid, width, height in placements: if not text: continue - artist = ax.text( - x_mid, - y_mid, - text, - ha="center", - va="center", - fontsize=plt.rcParams["font.size"] * 0.8, - zorder=4, - clip_on=True, - # A dark fill would otherwise swallow the text sitting on it. - path_effects=[pe.withStroke(linewidth=1.3, foreground="white")], - ) - left = transform.transform((x_mid - width / 2, y_mid))[0] - right = transform.transform((x_mid + width / 2, y_mid))[0] - if artist.get_window_extent(renderer).width > (right - left): + box = _box_pixels(transform, x_mid, y_mid, width, height) + room = (box[0] - _LABEL_PAD, box[1] - _LABEL_PAD) + for rotation in (0, 90): + artist = ax.text( + x_mid, + y_mid, + text, + ha="center", + va="center", + rotation=rotation, + fontsize=plt.rcParams["font.size"] * 0.8, + zorder=4, + clip_on=True, + # A dark fill would otherwise swallow the text sitting on it. + path_effects=[pe.withStroke(linewidth=1.3, foreground="white")], + ) + extent = artist.get_window_extent(renderer) + if extent.width <= room[0] and extent.height <= room[1]: + break artist.remove() +def _legend_placement(ax, handles, renderer): + """Where a legend of these handles fits, and in how many columns. + + One column beside the lanes is the natural home, but a figure can + name more values than its axes is tall, and the column then runs off + the bottom of the figure. Such a legend goes underneath instead, in + as many columns as the axes is wide enough to hold. + """ + # A membership swatch can be keyed on no value at all, and states None. + labels = [str(x.get_label() or "") for x in handles] + probe = ax.text(0, 0, max(labels, key=len), fontsize="small") + size = probe.get_window_extent(renderer) + probe.remove() + box = ax.get_window_extent(renderer) + # Legend rows are set a little further apart than the text is tall. + pitch = size.height * 1.6 + if len(handles) * pitch <= box.height: + return "beside", 1 + # A swatch and the gaps around it take about three text heights. + entry = size.width + 3.0 * size.height + columns = max(1, min(len(handles), int(box.width // entry))) + return "below", columns + + def plot_lanes( intervals, ax: plt.Axes | None = None, @@ -391,7 +437,9 @@ def plot_lanes( label Column holding the text drawn in each box. Values supply it by default: text as itself, a number as its digits, and a row which - states no value nothing, since its lane already names it. + states no value nothing, since its lane already names it. Text + too wide for its box is turned on its side, and dropped only + when it does not fit that way either. lanes The lanes to draw, in order. Names with no rows are kept as empty lanes, so two figures of different subjects still line up. @@ -407,7 +455,9 @@ def plot_lanes( Whether overlapping intervals are packed into sub-rows. legend Whether to draw a legend and any colorbars. False, or "off", - draws neither; anything else draws what the colors earn. + draws neither; anything else draws what the colors earn. A + legend naming more values than the axes is tall is laid out in + columns below it rather than one column off the figure. max_labels Draw no text at all past this many intervals. x_limits @@ -511,7 +561,13 @@ def plot_lanes( boxes.append(Rectangle((row["start"], low), width, height)) box_colors.append(row_color) placements.append( - (row["label"], row["start"] + width / 2, low + height / 2, width) + ( + row["label"], + row["start"] + width / 2, + low + height / 2, + width, + height, + ) ) if boxes: ax.add_collection( @@ -549,7 +605,6 @@ def plot_lanes( ax.spines[side].set_visible(False) if dated: _format_time_axis(ax, x_label or "time", "x") - _fit_labels(ax, placements, max_labels) if legend and legend != "off": for name, cmap, norm in colorbars: bar = ax.get_figure().colorbar( @@ -564,15 +619,41 @@ def plot_lanes( PatchArtist(facecolor=color, label=name) for name, color in legend_entries.items() ] - # A colorbar already occupies the strip beside the axes. - offset = 1.01 + 0.17 * len(colorbars) - ax.legend( - handles=handles, - loc="upper left", - bbox_to_anchor=(offset, 1.0), - frameon=False, - fontsize="small", - ) + figure = ax.get_figure() + figure.draw_without_rendering() + where, columns = _legend_placement(ax, handles, figure.canvas.get_renderer()) + if where == "beside": + # A colorbar already occupies the strip beside the axes. + offset = 1.01 + 0.17 * len(colorbars) + ax.legend( + handles=handles, + loc="upper left", + bbox_to_anchor=(offset, 1.0), + frameon=False, + fontsize="small", + ) + elif figure.get_layout_engine() is not None: + # The figure lays itself out, so it can keep the room this + # legend takes at its foot rather than the lanes giving it up. + figure.legend( + handles=handles, + loc="outside lower center", + ncol=columns, + frameon=False, + fontsize="small", + ) + else: + ax.legend( + handles=handles, + loc="upper center", + bbox_to_anchor=(0.5, -0.12), + ncol=columns, + frameon=False, + fontsize="small", + ) + # Fit the labels last: the legend and the colorbars have taken their + # room by now, so a label is measured against the box it lands in. + _fit_labels(ax, placements, max_labels) if show: plt.show() return ax diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index d2fbbbeba..9f95952bf 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -387,7 +387,9 @@ def path( color Passed to the lane renderer to override its colors. max_labels - Draw no lane text at all past this many intervals. + Draw no lane text at all past this many intervals. Under it, a + label too wide for its box is turned on its side rather than + dropped, so a lane of many short stretches still reads. ax An Axes to draw the lanes on. Column panels need their own figure, so passing this and naming columns is refused. @@ -431,8 +433,20 @@ def path( frame = _select_tracks(frame, tracks, chosen) lanes = list(dict.fromkeys(frame["lane"])) if ax is None: + # A legend naming more than the lanes are tall sits below them, so + # count the rows it will take there; without them the lanes give up + # the room instead and every bar is squeezed into a sliver. + # A lane which states no value takes one swatch, named for the lane. + stated = frame["value"] + swatches = ( + stated.dropna().nunique() + frame.loc[stated.isna(), "lane"].nunique() + ) + legend_rows = 0 if swatches <= len(lanes) else -(-swatches // 6) # Capped: a figure taller than a page is not more readable. - height = min(1.2 + 0.42 * len(lanes) + 1.1 * len(columns), 14.0) + height = min( + 1.2 + 0.42 * len(lanes) + 1.1 * len(columns) + 0.3 * legend_rows, + 14.0, + ) figure, all_axes = plt.subplots( 1 + len(columns), 1, diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index c09cdd7d5..eacc04601 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -167,6 +167,42 @@ def build_site_inventory() -> inv.Inventory: ).check() +def build_labeled_inventory(values: int, crs) -> inv.Inventory: + """One path of one label group, stating this many distinct values.""" + labels = tuple( + inv.OpticalPathLabel( + start_distance=float(x * 10), + end_distance=float(x * 10 + 8), + group="hole", + value=f"H{x % values:02d}", + ) + for x in range(24) + ) + return inv.Inventory( + coordinate_reference_system=crs, + networks=( + inv.Network( + code="DAS", + fiber_arrays=( + inv.FiberArray( + code="L2", + optical_paths=( + inv.OpticalPath( + name="holes", + location_code="00", + optical_components=( + inv.FiberSegment(name="run", optical_length=300.0), + ), + labels=labels, + ), + ), + ), + ), + ), + ), + ).check() + + @pytest.fixture(scope="module") def site(): """The inventory most tests draw.""" @@ -343,6 +379,25 @@ def test_all_tracks(self, site): # Components take their fixed colors, so the legend names the types. assert "FiberSegment" in _legend_labels(ax) + def test_room_is_kept_for_a_legend_which_names_many_values(self, site): + """A path naming more values than it has lanes needs a taller figure. + + The legend goes below the lanes there, and without the rows it + takes the lanes give up the room instead. + """ + crs = site.coordinate_reference_system + few = path(build_labeled_inventory(2, crs), "DAS.L2.00") + short = few.get_figure().get_size_inches()[1] + plt.close("all") + many = path(build_labeled_inventory(24, crs), "DAS.L2.00") + figure = many.get_figure() + # Same one lane either way, so only the legend can move the height. + assert _lanes(many) == _lanes(few) == ["components", "hole"] + assert figure.get_size_inches()[1] > short + figure.draw_without_rendering() + box = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) + assert box.y0 >= 0 and box.y1 <= figure.bbox.height + def test_tracks_selected_in_order(self, site): """tracks= picks lanes and orders them.""" ax = path(site, "DAS.L1.00", time="2026-06-10", tracks=("zone", "coupling")) diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 13d0b9ed3..5849ecf44 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -39,6 +39,28 @@ def _texts(ax): return [x.get_text() for x in ax.texts] +def _overflowing(ax): + """Labels drawn wider or taller, in pixels, than the box holding them.""" + figure = ax.get_figure() + figure.draw_without_rendering() + renderer = figure.canvas.get_renderer() + boxes = [x.get_extents() for x in _collections(ax)[0].get_paths()] + out = [] + for text in ax.texts: + drawn = text.get_window_extent(renderer) + middle = text.get_position() + for box in boxes: + if not (box.x0 <= middle[0] <= box.x1): + continue + corner = ax.transData.transform((box.x0, box.y0)) + far = ax.transData.transform((box.x1, box.y1)) + if drawn.width > abs(far[0] - corner[0]) or drawn.height > abs( + far[1] - corner[1] + ): + out.append(text.get_text()) + return out + + @pytest.fixture() def string_frame(): """Two lanes of named zones.""" @@ -245,6 +267,49 @@ def test_labels_fit_or_drop(self): ax = plot_lanes(frame, value="v") assert _texts(ax) == ["wide"] + def test_a_narrow_box_turns_its_label(self): + """Text too wide for its box is stood on end rather than dropped.""" + # Boxes narrower than the text but far taller than it is tall. + frame = pd.DataFrame( + { + "start": [0.0, 20.0], + "end": [1.6, 21.6], + "v": ["alpha zone", "beta zone"], + } + ) + _, ax = plt.subplots(figsize=(4, 4)) + plot_lanes(frame, ax=ax, value="v") + assert sorted(_texts(ax)) == ["alpha zone", "beta zone"] + assert {x.get_rotation() for x in ax.texts} == {90.0} + + def test_a_label_needs_clearance(self): + """A label the exact width of its box would touch the next one.""" + frame = pd.DataFrame({"start": [0.0], "end": [1.0], "v": ["tight"]}) + _, ax = plt.subplots(figsize=(4, 4)) + plot_lanes(frame, ax=ax, value="v") + figure = ax.get_figure() + figure.draw_without_rendering() + width = ax.texts[0].get_window_extent(figure.canvas.get_renderer()).width + box = ax.get_window_extent().width / 1.04 # the frame plus its padding + assert width <= box - 4.0 + + def test_no_drawn_label_overflows_its_box(self): + """Every label kept is measured against the axes it lands in. + + The legend takes its room after the boxes are drawn, so a label + judged before that is judged against an axes which no longer + exists by the time it is rendered. + """ + frame = pd.DataFrame( + { + "start": np.arange(20.0), + "end": np.arange(20.0) + 0.9, + "v": [f"value {x}" for x in range(20)], + } + ) + ax = plot_lanes(frame, value="v") + assert _overflowing(ax) == [] + def test_max_labels(self): """Past max_labels no text is drawn at all.""" frame = pd.DataFrame({"start": [0.0, 50.0], "end": [50.0, 100.0]}) @@ -495,6 +560,34 @@ def test_labels_decided_the_same_at_any_dpi(self): # two resolutions loses its labels. assert drawn[0] == drawn[1] + def test_a_tall_legend_goes_below_the_lanes(self, string_frame): + """A column naming more than the axes is tall runs off the figure.""" + names = [f"value {x:02d}" for x in range(30)] + frame = pd.DataFrame( + { + "start": np.arange(float(len(names))), + "end": np.arange(float(len(names))) + 1.0, + "v": names, + } + ) + figure, ax = plt.subplots(figsize=(8, 3), layout="constrained") + plot_lanes(frame, ax=ax, value="v") + # It belongs to the figure now, which is what keeps room for it. + assert ax.get_legend() is None + legend = figure.legends[0] + figure.draw_without_rendering() + box = legend.get_window_extent(figure.canvas.get_renderer()) + assert box.y0 >= 0 and box.y1 <= figure.bbox.height + # Laid out in columns rather than the one column which did not fit. + assert box.width > box.height + + def test_a_short_legend_stays_beside_them(self, string_frame): + """Few enough values still read best in one column at the side.""" + ax = plot_lanes(string_frame, lane="group", value="value") + assert ax.get_figure().legends == [] + box = ax.get_legend().get_window_extent() + assert box.x0 >= ax.get_window_extent().x1 + def test_legend_off(self, string_frame): """legend=False draws none.""" ax = plot_lanes(string_frame, lane="group", value="value", legend=False) From 0018c83f29dabfec249f02b7f2e03c36e749aaa6 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 21:55:48 +0200 Subject: [PATCH 3/7] Decide a label from its outlines, and a legend from what it drew Review of the two commits before this one turned up four ways they were still guessing. Only a constrained layout keeps room for a legend outside the axes, so "outside lower center" on any other figure put it under the canvas. The engine is now checked for what it is, and a figure which lays out nothing is given the room explicitly: the legend goes at its foot and the axes is moved up by what the legend took. How wide matplotlib draws a legend column was estimated from the longest label, and the estimate was low enough that thirty values overhung an eight inch figure by forty pixels. The widest layout is drawn and then narrowed until it is inside the figure, which needs no estimate. A renderer rounds each glyph to whole pixels, so text comes out a tenth wider at 50 dpi than at 300 and the fit was partly a question about resolution. Turning a label on its side asks that question twice, which made it much worse: across a sweep of label widths, twenty-one of thirty-six were decided differently at different dpi, against four before any of this. Labels are now measured from their outlines, in points, and none of the thirty-six is. Lastly path() counted every distinct value toward the room to keep for a legend, including numbers, which earn a colorbar, and every value in a figure given one color, which earns no legend at all. --- dascore/viz/_lanes.py | 166 ++++++++++++++++++--------- dascore/viz/inventory.py | 14 ++- tests/test_viz/test_inventory_viz.py | 20 +++- tests/test_viz/test_lanes.py | 57 ++++++--- 4 files changed, 178 insertions(+), 79 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 76dcab89d..9f0a1a942 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -12,6 +12,7 @@ import colorsys import datetime from collections.abc import Mapping, Sequence +from functools import lru_cache import matplotlib.dates as mdates import matplotlib.patheffects as pe @@ -20,8 +21,11 @@ import pandas as pd from matplotlib.collections import PatchCollection from matplotlib.colors import BoundaryNorm, ListedColormap +from matplotlib.font_manager import FontProperties +from matplotlib.layout_engine import ConstrainedLayoutEngine from matplotlib.patches import Patch as PatchArtist from matplotlib.patches import Rectangle +from matplotlib.textpath import TextPath from dascore.exceptions import ParameterError from dascore.utils.intervals import normalize_value, value_kind @@ -43,10 +47,17 @@ # and alternating shade separates two which still land on a similar hue. _GOLDEN_STEP = 0.6180339887498949 -# Pixels of clearance a label needs inside its box. Without it a label the -# exact width of its box touches the one in the next box, and the two read -# as one word. -_LABEL_PAD = 4.0 +# Everything a label is measured in, so the same figure keeps the same +# labels whatever resolution it is drawn at. Rasterized glyphs round to +# whole pixels, which makes text as much as a tenth wider at 50 dpi than +# at 300, so both the text and the box it must fit are asked for in +# points instead of the pixels the renderer works in. +_MEASURED_DPI = 72.0 + +# Clearance a label needs inside its box, in points. Without it a label +# the exact width of its box touches the one in the next box, and the two +# read as one word. +_LABEL_PAD = 3.0 # The fraction of the x axis hatched where a bar runs off the end of it. _OPEN_FRACTION = 0.02 @@ -192,6 +203,8 @@ def _string_colors(frame, vocabulary=None, cmap_name=STRING_CMAP) -> dict: The vocabulary widens the palette beyond what this frame holds, so a figure of part of a subject colors it as a figure of all of it does. + Adding a value to the vocabulary itself moves the colors of the ones + which sort after it, and crossing the wheel moves all of them. """ seen = list(frame["value"].tolist()) + list(vocabulary or []) values = sorted({x for x in seen if isinstance(x, str) and x != ""}) @@ -320,11 +333,24 @@ def _draw_open_edges(ax, rows, y_low, height, colors, span): ax.add_collection(patches) -def _box_pixels(transform, x_mid, y_mid, width, height): - """The size of one box, in pixels, however the axes is scaled.""" +def _box_points(transform, scale, x_mid, y_mid, width, height): + """The size of one box, in points, however the axes is scaled.""" low = transform.transform((x_mid - width / 2, y_mid - height / 2)) high = transform.transform((x_mid + width / 2, y_mid + height / 2)) - return abs(high[0] - low[0]), abs(high[1] - low[1]) + return abs(high[0] - low[0]) * scale, abs(high[1] - low[1]) * scale + + +@lru_cache(maxsize=1024) +def _text_points(text: str, size: float) -> tuple[float, float]: + """The room a label takes, in points, at any resolution. + + A renderer rounds each glyph to whole pixels, so the same text comes + out a tenth wider at 50 dpi than at 300. Measuring what it drew would + let the resolution decide which labels a figure keeps; the outlines + behind it are the same however finely they are drawn. + """ + box = TextPath((0, 0), text, prop=FontProperties(size=size)).get_extents() + return box.width, box.height def _fit_labels(ax, placements, max_labels): @@ -339,58 +365,102 @@ def _fit_labels(ax, placements, max_labels): return figure = ax.get_figure() # Lay the figure out before measuring: a label is compared against its - # box in pixels, and both move when the axes does. The legend and the + # box, and the box moves when the axes does. The legend and the # colorbars are drawn by now, so this is the geometry it lands in. figure.draw_without_rendering() - renderer = figure.canvas.get_renderer() transform = ax.transData + scale = _MEASURED_DPI / figure.dpi + size = plt.rcParams["font.size"] * 0.8 for text, x_mid, y_mid, width, height in placements: if not text: continue - box = _box_pixels(transform, x_mid, y_mid, width, height) + box = _box_points(transform, scale, x_mid, y_mid, width, height) room = (box[0] - _LABEL_PAD, box[1] - _LABEL_PAD) + taken = _text_points(text, size) for rotation in (0, 90): - artist = ax.text( + # Turning the text swaps which way it has to fit. + if rotation: + taken = taken[::-1] + if taken[0] > room[0] or taken[1] > room[1]: + continue + ax.text( x_mid, y_mid, text, ha="center", va="center", rotation=rotation, - fontsize=plt.rcParams["font.size"] * 0.8, + fontsize=size, zorder=4, clip_on=True, # A dark fill would otherwise swallow the text sitting on it. path_effects=[pe.withStroke(linewidth=1.3, foreground="white")], ) - extent = artist.get_window_extent(renderer) - if extent.width <= room[0] and extent.height <= room[1]: - break - artist.remove() + break -def _legend_placement(ax, handles, renderer): - """Where a legend of these handles fits, and in how many columns. +def _fits_beside(ax, handles, renderer) -> bool: + """Whether one column of these handles is shorter than the axes. - One column beside the lanes is the natural home, but a figure can - name more values than its axes is tall, and the column then runs off - the bottom of the figure. Such a legend goes underneath instead, in - as many columns as the axes is wide enough to hold. + A figure can name more values than its axes is tall, and the column + beside it then runs off the bottom of the page. """ - # A membership swatch can be keyed on no value at all, and states None. - labels = [str(x.get_label() or "") for x in handles] - probe = ax.text(0, 0, max(labels, key=len), fontsize="small") - size = probe.get_window_extent(renderer) - probe.remove() - box = ax.get_window_extent(renderer) + figure = ax.get_figure() + scale = _MEASURED_DPI / figure.dpi + size = plt.rcParams["font.size"] * 0.833 # Legend rows are set a little further apart than the text is tall. - pitch = size.height * 1.6 - if len(handles) * pitch <= box.height: - return "beside", 1 - # A swatch and the gaps around it take about three text heights. - entry = size.width + 3.0 * size.height - columns = max(1, min(len(handles), int(box.width // entry))) - return "below", columns + pitch = _text_points("Ay", size)[1] * 1.9 + room = ax.get_window_extent(renderer).height * scale + return len(handles) * pitch <= room + + +def _legend_below(figure, ax, handles, renderer, outside): + """Lay a legend out under the lanes, in as many columns as fit. + + How wide matplotlib draws a column is not worth predicting, so the + widest layout is drawn and narrowed until it is inside the figure. + """ + columns = len(handles) + while True: + if outside: + # The figure lays itself out, so it can keep the room this + # legend takes at its foot rather than the lanes giving it up. + legend = figure.legend( + handles=handles, + loc="outside lower center", + ncol=columns, + frameon=False, + fontsize="small", + ) + else: + legend = figure.legend( + handles=handles, + loc="lower center", + ncol=columns, + frameon=False, + fontsize="small", + ) + figure.draw_without_rendering() + box = legend.get_window_extent(renderer) + if columns == 1 or box.width <= figure.bbox.width: + break + legend.remove() + # Overshooting by a lot is common, so step to what did fit. + columns = max(1, min(columns - 1, int(columns * figure.bbox.width / box.width))) + if outside: + return legend + # Nothing lays this figure out, so the axes gives up the room itself. + room = box.height / figure.bbox.height + position = ax.get_position() + ax.set_position( + ( + position.x0, + position.y0 + room, + position.width, + max(position.height - room, 0.1), + ) + ) + return legend def plot_lanes( @@ -621,8 +691,8 @@ def plot_lanes( ] figure = ax.get_figure() figure.draw_without_rendering() - where, columns = _legend_placement(ax, handles, figure.canvas.get_renderer()) - if where == "beside": + renderer = figure.canvas.get_renderer() + if _fits_beside(ax, handles, renderer): # A colorbar already occupies the strip beside the axes. offset = 1.01 + 0.17 * len(colorbars) ax.legend( @@ -632,25 +702,11 @@ def plot_lanes( frameon=False, fontsize="small", ) - elif figure.get_layout_engine() is not None: - # The figure lays itself out, so it can keep the room this - # legend takes at its foot rather than the lanes giving it up. - figure.legend( - handles=handles, - loc="outside lower center", - ncol=columns, - frameon=False, - fontsize="small", - ) else: - ax.legend( - handles=handles, - loc="upper center", - bbox_to_anchor=(0.5, -0.12), - ncol=columns, - frameon=False, - fontsize="small", - ) + # Only a constrained layout keeps room for a legend outside + # the axes; any other figure has to be given it explicitly. + outside = isinstance(figure.get_layout_engine(), ConstrainedLayoutEngine) + _legend_below(figure, ax, handles, renderer, outside) # Fit the labels last: the legend and the colorbars have taken their # room by now, so a label is measured against the box it lands in. _fit_labels(ax, placements, max_labels) diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index 9f95952bf..de5c0c020 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -435,12 +435,16 @@ def path( if ax is None: # A legend naming more than the lanes are tall sits below them, so # count the rows it will take there; without them the lanes give up - # the room instead and every bar is squeezed into a sliver. - # A lane which states no value takes one swatch, named for the lane. + # the room instead and every bar is squeezed into a sliver. Only + # what earns a swatch counts: a lane of numbers earns a colorbar, + # one color for every lane earns nothing, and a lane which states + # no value earns one swatch named for the lane itself. stated = frame["value"] - swatches = ( - stated.dropna().nunique() + frame.loc[stated.isna(), "lane"].nunique() - ) + if isinstance(color, str): + swatches = 0 + else: + named = stated[[isinstance(x, str) for x in stated]] + swatches = named.nunique() + frame.loc[stated.isna(), "lane"].nunique() legend_rows = 0 if swatches <= len(lanes) else -(-swatches // 6) # Capped: a figure taller than a page is not more readable. height = min( diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index eacc04601..8a86e8ef3 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -387,17 +387,31 @@ def test_room_is_kept_for_a_legend_which_names_many_values(self, site): """ crs = site.coordinate_reference_system few = path(build_labeled_inventory(2, crs), "DAS.L2.00") - short = few.get_figure().get_size_inches()[1] + few.get_figure().draw_without_rendering() + short, lanes = few.get_figure().get_size_inches()[1], _lanes(few) + room = few.get_window_extent().height / few.get_figure().dpi plt.close("all") many = path(build_labeled_inventory(24, crs), "DAS.L2.00") figure = many.get_figure() + figure.draw_without_rendering() # Same one lane either way, so only the legend can move the height. - assert _lanes(many) == _lanes(few) == ["components", "hole"] + assert _lanes(many) == lanes == ["components", "hole"] assert figure.get_size_inches()[1] > short - figure.draw_without_rendering() + # The room is kept for the legend, not taken from the lanes. + assert many.get_window_extent().height / figure.dpi >= room box = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) assert box.y0 >= 0 and box.y1 <= figure.bbox.height + def test_one_color_for_every_lane_needs_no_legend_room(self, site): + """A figure which names no value has no legend to keep room for.""" + crowded = build_labeled_inventory(24, site.coordinate_reference_system) + named = path(crowded, "DAS.L2.00") + tall = named.get_figure().get_size_inches()[1] + plt.close("all") + plain = path(crowded, "DAS.L2.00", color="red") + assert plain.get_figure().legends == [] + assert plain.get_figure().get_size_inches()[1] < tall + def test_tracks_selected_in_order(self, site): """tracks= picks lanes and orders them.""" ax = path(site, "DAS.L1.00", time="2026-06-10", tracks=("zone", "coupling")) diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 5849ecf44..cbedc2c81 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -544,24 +544,32 @@ def test_a_wide_palette_is_still_stable(self): together = plot_lanes(whole, value="v") assert np.allclose(first, _collections(together)[0].get_facecolors()[0]) - def test_labels_decided_the_same_at_any_dpi(self): - """Whether a label fits is a question about the figure, not its dpi.""" - frame = pd.DataFrame( - {"start": [0.0], "end": [1.0], "v": ["a rather long label"]} - ) + @pytest.mark.parametrize("length", range(4, 34, 3)) + def test_labels_decided_the_same_at_any_dpi(self, length): + """Whether a label fits is a question about the figure, not its dpi. + + A width is swept because only a label near the edge of its box + can be decided two ways, and every width is near some box's edge. + """ + frame = pd.DataFrame({"start": [0.0], "end": [1.0], "v": ["x" * length]}) drawn = [] - for dpi in (50, 200): + for dpi in (50, 100, 300): _, ax = plt.subplots(figsize=(2, 1), dpi=dpi) plot_lanes(frame, ax=ax, value="v") - drawn.append(_texts(ax)) + drawn.append((_texts(ax), [x.get_rotation() for x in ax.texts])) plt.close("all") - # Measuring text in points against a box in pixels answers this - # differently at each dpi, which is how the same figure saved at - # two resolutions loses its labels. - assert drawn[0] == drawn[1] + # A renderer rounds each glyph to whole pixels, so measuring what + # it drew is how the same figure saved at two resolutions keeps + # different labels. + assert len(set(map(str, drawn))) == 1 + + @pytest.mark.parametrize("engine", [None, "constrained", "tight"]) + def test_a_tall_legend_stays_on_the_page(self, engine): + """A column naming more than the axes is tall runs off the figure. - def test_a_tall_legend_goes_below_the_lanes(self, string_frame): - """A column naming more than the axes is tall runs off the figure.""" + Only a constrained layout keeps room for a legend outside the + axes, so the other figures have to be given it explicitly. + """ names = [f"value {x:02d}" for x in range(30)] frame = pd.DataFrame( { @@ -570,16 +578,33 @@ def test_a_tall_legend_goes_below_the_lanes(self, string_frame): "v": names, } ) - figure, ax = plt.subplots(figsize=(8, 3), layout="constrained") + figure, ax = plt.subplots(figsize=(8, 3), layout=engine) plot_lanes(frame, ax=ax, value="v") # It belongs to the figure now, which is what keeps room for it. assert ax.get_legend() is None legend = figure.legends[0] figure.draw_without_rendering() box = legend.get_window_extent(figure.canvas.get_renderer()) + assert box.x0 >= 0 and box.x1 <= figure.bbox.width assert box.y0 >= 0 and box.y1 <= figure.bbox.height - # Laid out in columns rather than the one column which did not fit. - assert box.width > box.height + # Every value is still named; none was dropped to make it fit. + assert len(legend.get_texts()) == len(names) + + def test_a_legend_below_leaves_the_lanes_their_room(self): + """Where nothing lays the figure out the axes gives up the room.""" + names = [f"value {x:02d}" for x in range(30)] + frame = pd.DataFrame( + { + "start": np.arange(float(len(names))), + "end": np.arange(float(len(names))) + 1.0, + "v": names, + } + ) + figure, ax = plt.subplots(figsize=(8, 3)) + plot_lanes(frame, ax=ax, value="v") + figure.draw_without_rendering() + legend = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) + assert ax.get_window_extent().y0 >= legend.y1 def test_a_short_legend_stays_beside_them(self, string_frame): """Few enough values still read best in one column at the side.""" From f67c86cb293419009f3ef94df620edbc0aae2dbd Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 22:14:01 +0200 Subject: [PATCH 4/7] Keep a legend to the axes it was handed, and cache what the font decides Three things the PR bots found. A legend under an axes was drawn at the foot of the whole figure while only that axes moved up, so in a figure of several panels it covered whichever one sat below. It is the axes' own legend now, anchored at the foot of the axes, and the axes rises by exactly what the legend took, so the two together cover what the axes covered before. Only a figure which lays itself out is asked for room outside the axes at all. Measuring a label as outlines is slow enough to want a cache, and the key held only the text and its size. The font family, style, weight and stretch all change the answer -- a name is eight percent wider in the mono family than the sans -- so a figure drawn under one rc_context and another under a different one shared measurements they did not agree on. The font is part of the key now, and usetex is passed through. Lastly path() counted only strings toward the legend it makes room for, but a mapping names whatever it holds, numbers included. --- dascore/viz/_lanes.py | 58 +++++++++++++++++++++------- dascore/viz/inventory.py | 42 ++++++++++++++------ tests/test_viz/test_inventory_viz.py | 18 +++++++++ tests/test_viz/test_lanes.py | 57 +++++++++++++++------------ 4 files changed, 126 insertions(+), 49 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 9f0a1a942..aa2b64c9b 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -341,6 +341,21 @@ def _box_points(transform, scale, x_mid, y_mid, width, height): @lru_cache(maxsize=1024) +def _measure_text(text: str, size: float, font: tuple, usetex: bool): + """Lay one label out as outlines, which is slow enough to cache.""" + family, style, variant, weight, stretch = font + prop = FontProperties( + family=list(family), + style=style, + variant=variant, + weight=weight, + stretch=stretch, + size=size, + ) + box = TextPath((0, 0), text, prop=prop, usetex=usetex).get_extents() + return box.width, box.height + + def _text_points(text: str, size: float) -> tuple[float, float]: """The room a label takes, in points, at any resolution. @@ -349,8 +364,18 @@ def _text_points(text: str, size: float) -> tuple[float, float]: let the resolution decide which labels a figure keeps; the outlines behind it are the same however finely they are drawn. """ - box = TextPath((0, 0), text, prop=FontProperties(size=size)).get_extents() - return box.width, box.height + # The font is part of the answer, so it is part of what is cached: + # the same label at the same size is a different width in a different + # family, and a style can change one between two figures. + family = plt.rcParams["font.family"] + font = ( + (family,) if isinstance(family, str) else tuple(family), + plt.rcParams["font.style"], + plt.rcParams["font.variant"], + plt.rcParams["font.weight"], + plt.rcParams["font.stretch"], + ) + return _measure_text(text, size, font, plt.rcParams["text.usetex"]) def _fit_labels(ax, placements, max_labels): @@ -418,13 +443,17 @@ def _legend_below(figure, ax, handles, renderer, outside): """Lay a legend out under the lanes, in as many columns as fit. How wide matplotlib draws a column is not worth predicting, so the - widest layout is drawn and narrowed until it is inside the figure. + widest layout is drawn and narrowed until it is inside the room it + has. Narrowing it further only makes it taller, so a legend which is + still too wide in one column is as close as column count can get. """ + # Only a laid-out figure can be asked for room outside the axes; any + # other belongs to whoever built it, so the legend stays in the space + # the axes it was given already occupies. + room = figure.bbox.width if outside else ax.get_window_extent(renderer).width columns = len(handles) while True: if outside: - # The figure lays itself out, so it can keep the room this - # legend takes at its foot rather than the lanes giving it up. legend = figure.legend( handles=handles, loc="outside lower center", @@ -433,31 +462,34 @@ def _legend_below(figure, ax, handles, renderer, outside): fontsize="small", ) else: - legend = figure.legend( + legend = ax.legend( handles=handles, - loc="lower center", + loc="upper center", + bbox_to_anchor=(0.5, 0.0), + borderaxespad=0.0, ncol=columns, frameon=False, fontsize="small", ) figure.draw_without_rendering() box = legend.get_window_extent(renderer) - if columns == 1 or box.width <= figure.bbox.width: + if columns == 1 or box.width <= room: break legend.remove() # Overshooting by a lot is common, so step to what did fit. - columns = max(1, min(columns - 1, int(columns * figure.bbox.width / box.width))) + columns = max(1, min(columns - 1, int(columns * room / box.width))) if outside: return legend - # Nothing lays this figure out, so the axes gives up the room itself. - room = box.height / figure.bbox.height + # The legend hangs off the foot of the axes, so the axes rises by what + # it took and the pair together cover what the axes covered before. + taken = box.height / figure.bbox.height position = ax.get_position() ax.set_position( ( position.x0, - position.y0 + room, + position.y0 + taken, position.width, - max(position.height - room, 0.1), + max(position.height - taken, 0.1), ) ) return legend diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index de5c0c020..e03bfd275 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -434,18 +434,15 @@ def path( lanes = list(dict.fromkeys(frame["lane"])) if ax is None: # A legend naming more than the lanes are tall sits below them, so - # count the rows it will take there; without them the lanes give up - # the room instead and every bar is squeezed into a sliver. Only - # what earns a swatch counts: a lane of numbers earns a colorbar, - # one color for every lane earns nothing, and a lane which states - # no value earns one swatch named for the lane itself. - stated = frame["value"] - if isinstance(color, str): - swatches = 0 - else: - named = stated[[isinstance(x, str) for x in stated]] - swatches = named.nunique() + frame.loc[stated.isna(), "lane"].nunique() - legend_rows = 0 if swatches <= len(lanes) else -(-swatches // 6) + # keep the rows it will take there; without them the lanes give up + # the room instead and every bar is squeezed into a sliver. How + # many columns it ends up in is the renderer's to decide, so this + # is an estimate, and one which is low costs only some of the room + # it was meant to save. + swatches = _legend_size(frame, color) + width = (figsize or (10.0, 0.0))[0] + per_row = max(1, int(width // 1.6)) + legend_rows = 0 if swatches <= len(lanes) else -(-swatches // per_row) # Capped: a figure taller than a page is not more readable. height = min( 1.2 + 0.42 * len(lanes) + 1.1 * len(columns) + 0.3 * legend_rows, @@ -529,6 +526,27 @@ def one(value): return low, high +def _legend_size(frame, color) -> int: + """How many swatches a legend of these lanes would name. + + Only what earns one counts: a lane of numbers earns a colorbar and + reads from that, one color for every lane earns no legend at all, + and a lane which states no value earns one swatch named for itself. + A mapping names whatever it holds, numbers included. + """ + if isinstance(color, str): + return 0 + keyed = set() + if isinstance(color, Mapping): + for name, entry in color.items(): + # Keyed by lane it holds a mapping of values; keyed by value + # the key is the value itself. + keyed.update(entry if isinstance(entry, Mapping) else {name}) + stated = frame["value"] + named = stated[[isinstance(x, str) or x in keyed for x in stated]] + return int(named.nunique() + frame.loc[stated.isna(), "lane"].nunique()) + + def _lane_colors(color): """Pin the tracks whose vocabulary is closed, honoring an override.""" if color is not None: diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index 8a86e8ef3..aa4dca6a7 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -6,6 +6,7 @@ import matplotlib.pyplot as plt import numpy as np +import pandas as pd import pytest from matplotlib.collections import LineCollection, PatchCollection @@ -16,6 +17,7 @@ from dascore.viz.inventory import ( COMPONENT_COLORS, _distance_window, + _legend_size, map_path, path, timeline, @@ -402,6 +404,22 @@ def test_room_is_kept_for_a_legend_which_names_many_values(self, site): box = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) assert box.y0 >= 0 and box.y1 <= figure.bbox.height + def test_a_mapping_names_its_numbers_too(self, site): + """A number is a colorbar until a mapping gives it a swatch.""" + frame = pd.DataFrame( + {"lane": ["count"] * 3, "value": [0, 1, 2], "start": 0.0, "end": 1.0} + ) + assert _legend_size(frame, None) == 0 + assert _legend_size(frame, "red") == 0 + assert _legend_size(frame, {0: "red", 1: "blue", 2: "green"}) == 3 + assert _legend_size(frame, {"count": {0: "red", 1: "blue"}}) == 2 + + def test_a_narrow_figure_still_sizes_its_legend(self, site): + """The columns a legend is divided into cannot fall to none.""" + crowded = build_labeled_inventory(24, site.coordinate_reference_system) + ax = path(crowded, "DAS.L2.00", figsize=(1.0, 2.0)) + assert ax.get_figure().get_size_inches()[0] == 1.0 + def test_one_color_for_every_lane_needs_no_legend_room(self, site): """A figure which names no value has no legend to keep room for.""" crowded = build_labeled_inventory(24, site.coordinate_reference_system) diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index cbedc2c81..69766297a 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -39,6 +39,23 @@ def _texts(ax): return [x.get_text() for x in ax.texts] +def _legend_of(figure, ax): + """The legend a figure grew, whichever of the two owns it.""" + return figure.legends[0] if figure.legends else ax.get_legend() + + +def _many_values(count=30): + """A frame of one lane naming more values than an axes is tall.""" + names = [f"value {x:02d}" for x in range(count)] + return names, pd.DataFrame( + { + "start": np.arange(float(count)), + "end": np.arange(float(count)) + 1.0, + "v": names, + } + ) + + def _overflowing(ax): """Labels drawn wider or taller, in pixels, than the box holding them.""" figure = ax.get_figure() @@ -570,19 +587,10 @@ def test_a_tall_legend_stays_on_the_page(self, engine): Only a constrained layout keeps room for a legend outside the axes, so the other figures have to be given it explicitly. """ - names = [f"value {x:02d}" for x in range(30)] - frame = pd.DataFrame( - { - "start": np.arange(float(len(names))), - "end": np.arange(float(len(names))) + 1.0, - "v": names, - } - ) + names, frame = _many_values() figure, ax = plt.subplots(figsize=(8, 3), layout=engine) plot_lanes(frame, ax=ax, value="v") - # It belongs to the figure now, which is what keeps room for it. - assert ax.get_legend() is None - legend = figure.legends[0] + legend = _legend_of(figure, ax) figure.draw_without_rendering() box = legend.get_window_extent(figure.canvas.get_renderer()) assert box.x0 >= 0 and box.x1 <= figure.bbox.width @@ -590,21 +598,22 @@ def test_a_tall_legend_stays_on_the_page(self, engine): # Every value is still named; none was dropped to make it fit. assert len(legend.get_texts()) == len(names) - def test_a_legend_below_leaves_the_lanes_their_room(self): - """Where nothing lays the figure out the axes gives up the room.""" - names = [f"value {x:02d}" for x in range(30)] - frame = pd.DataFrame( - { - "start": np.arange(float(len(names))), - "end": np.arange(float(len(names))) + 1.0, - "v": names, - } - ) - figure, ax = plt.subplots(figsize=(8, 3)) + def test_a_legend_below_stays_inside_the_axes_it_was_given(self): + """A figure nobody laid out may hold other axes under this one. + + The legend is the axes' own, so it takes the axes' room rather + than the space a neighbor below is sitting in. + """ + _, frame = _many_values() + figure, (ax, below) = plt.subplots(2, 1, figsize=(8, 6)) + before = ax.get_window_extent().frozen() plot_lanes(frame, ax=ax, value="v") figure.draw_without_rendering() - legend = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) - assert ax.get_window_extent().y0 >= legend.y1 + box = _legend_of(figure, ax).get_window_extent(figure.canvas.get_renderer()) + # Under the lanes, and no lower than the lanes used to reach. + assert box.y1 <= ax.get_window_extent().y0 + 1 + assert box.y0 >= before.y0 - 1 + assert box.y0 >= below.get_window_extent().y1 def test_a_short_legend_stays_beside_them(self, string_frame): """Few enough values still read best in one column at the side.""" From 7d13ec97ce14458b930f574eadde0408df1383ca Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 22:44:43 +0200 Subject: [PATCH 5/7] Measure the legend and the labels instead of predicting them Six reviewers over the three commits before this one, and most of what they found came back to guessing at something that could be measured. Matplotlib already measures text without a renderer, so the outlines and the cache keyed on the font behind them are gone. It is thirty times faster, which is why there is no cache left to key wrongly; it returns the advance rather than the ink, which is the right number for asking whether a label fits; and it does not fall over on a label of nothing but spaces, which the outlines did, taking the whole figure with them. Labels of several lines are measured a line at a time and stacked, since neither measurer reads a newline the way the text artist lays one out. Whether a legend fits beside the lanes was predicted from a synthetic row pitch and got the near cases wrong in both directions. It is drawn beside, measured, and moved below only if it really is taller than the axes. That deletes the pitch fudge and the helper holding it. Two ways the legend could take room that was not its to take. Asking a figure for room outside the axes moves every other axes on it, so it is now only ever asked of a figure the call built itself -- path() says so explicitly, since it builds the figure but hands over an axes. And where the axes gives up the room instead, it gives up at most half of itself and remembers what it gave, so a legend taller than the axes can no longer push the lanes off the page and drawing twice into one axes does not shrink it twice. path() reserved rows for a legend below, and the reserved room was then enough for the legend to sit beside instead, leaving the room unused. It decides now, and says which it decided, rather than growing the figure and letting the renderer choose again on the strength of that growth. The rows themselves come from the labels rather than from a fixed column width, so long names no longer overflow what was kept for them, and a mapping which names some of a lane's values reserves room for those and not for the rest. Lastly the hue walk told two values apart by a light and a dark shade, which put a pair of near-identical colors thirty-five values in; it walks five shades now, which pushes that past what a legend can carry anyway. Three of the tests turned out to pin nothing: the one for overlapping labels passed against the old code, where no label was drawn at all; the one for label clearance had two hundred pixels of slack; and the one for a narrow figure asserted only that figsize was passed through. Each now fails against dev, and the ones for whitespace, several lines, a vector backend and a legend naming nothing are new. --- dascore/viz/_lanes.py | 187 ++++++++++++++++----------- dascore/viz/inventory.py | 68 +++++++--- tests/test_viz/test_inventory_viz.py | 54 ++++++-- tests/test_viz/test_lanes.py | 81 ++++++++++-- 4 files changed, 268 insertions(+), 122 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index aa2b64c9b..498062488 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -12,8 +12,8 @@ import colorsys import datetime from collections.abc import Mapping, Sequence -from functools import lru_cache +import matplotlib.cbook as cbook import matplotlib.dates as mdates import matplotlib.patheffects as pe import matplotlib.pyplot as plt @@ -25,7 +25,7 @@ from matplotlib.layout_engine import ConstrainedLayoutEngine from matplotlib.patches import Patch as PatchArtist from matplotlib.patches import Rectangle -from matplotlib.textpath import TextPath +from matplotlib.textpath import text_to_path from dascore.exceptions import ParameterError from dascore.utils.intervals import normalize_value, value_kind @@ -54,6 +54,18 @@ # points instead of the pixels the renderer works in. _MEASURED_DPI = 72.0 +# What matplotlib sets successive lines of one label apart by, as a +# multiple of the font size. +_LINE_SPACING = 1.2 + +# A legend entry is its label plus a swatch and the gaps around it, which +# come to about this many times the text height. +_SWATCH_WIDTH = 3.0 + +# What matplotlib sets legend rows apart by, as a multiple of the size of +# the text in them. +_LEGEND_PITCH = 1.6 + # Clearance a label needs inside its box, in points. Without it a label # the exact width of its box touches the one in the next box, and the two # read as one word. @@ -185,16 +197,26 @@ def _pack_rows(frame) -> np.ndarray: return np.minimum(rows, _MAX_SUB_ROWS - 1) +# Shades the hue circle is walked at. Two values far enough apart in the +# walk come back to nearly the same hue, so they are told apart by shade +# instead; a prime number of them keeps that from lining up with the walk. +_SHADES = ((0.62, 0.72), (0.38, 0.92), (0.85, 0.55), (0.50, 0.98), (0.72, 0.85)) + + def _wide_colors(values) -> dict: - """One distinct color per value, past what the wheel can hold.""" + """One distinct color per value, past what the wheel can hold. + + No two are ever the same, since the walk never lands twice on one + hue, but past fifty or so they stop being easy to tell apart. A + legend that long is asking more of color than color can carry. + """ out = {} for index, value in enumerate(values): hue = (index * _GOLDEN_STEP) % 1.0 - pale = index % 2 # Held near tab20's own saturation so the two schemes sit together # in a figure whose other lanes are still colored from the wheel. - rgb = colorsys.hsv_to_rgb(hue, 0.38 if pale else 0.62, 0.92 if pale else 0.72) - out[value] = (*rgb, 1.0) + saturation, brightness = _SHADES[index % len(_SHADES)] + out[value] = (*colorsys.hsv_to_rgb(hue, saturation, brightness), 1.0) return out @@ -340,42 +362,27 @@ def _box_points(transform, scale, x_mid, y_mid, width, height): return abs(high[0] - low[0]) * scale, abs(high[1] - low[1]) * scale -@lru_cache(maxsize=1024) -def _measure_text(text: str, size: float, font: tuple, usetex: bool): - """Lay one label out as outlines, which is slow enough to cache.""" - family, style, variant, weight, stretch = font - prop = FontProperties( - family=list(family), - style=style, - variant=variant, - weight=weight, - stretch=stretch, - size=size, - ) - box = TextPath((0, 0), text, prop=prop, usetex=usetex).get_extents() - return box.width, box.height - - def _text_points(text: str, size: float) -> tuple[float, float]: """The room a label takes, in points, at any resolution. A renderer rounds each glyph to whole pixels, so the same text comes - out a tenth wider at 50 dpi than at 300. Measuring what it drew would - let the resolution decide which labels a figure keeps; the outlines - behind it are the same however finely they are drawn. + out a tenth wider at 50 dpi than at 300, and measuring what it drew + would let the resolution decide which labels a figure keeps. These + are the font's own metrics, which every resolution shares. """ - # The font is part of the answer, so it is part of what is cached: - # the same label at the same size is a different width in a different - # family, and a style can change one between two figures. - family = plt.rcParams["font.family"] - font = ( - (family,) if isinstance(family, str) else tuple(family), - plt.rcParams["font.style"], - plt.rcParams["font.variant"], - plt.rcParams["font.weight"], - plt.rcParams["font.stretch"], - ) - return _measure_text(text, size, font, plt.rcParams["text.usetex"]) + prop = FontProperties(size=size) + # However the text artist will read this string, it is measured the + # same way, or the two disagree about how much room it takes. + parse = plt.rcParams["text.parse_math"] and cbook.is_math_text(text) + ismath = "TeX" if plt.rcParams["text.usetex"] else parse + # Matplotlib lays a newline out as another line; the metrics do not. + lines = text.split("\n") + measured = [ + text_to_path.get_text_width_height_descent(x, prop, ismath) for x in lines + ] + width = max(x[0] for x in measured) + height = max(x[1] for x in measured) + (len(lines) - 1) * size * _LINE_SPACING + return width, height def _fit_labels(ax, placements, max_labels): @@ -395,6 +402,7 @@ def _fit_labels(ax, placements, max_labels): figure.draw_without_rendering() transform = ax.transData scale = _MEASURED_DPI / figure.dpi + size = plt.rcParams["font.size"] * 0.8 for text, x_mid, y_mid, width, height in placements: if not text: @@ -424,33 +432,43 @@ def _fit_labels(ax, placements, max_labels): break -def _fits_beside(ax, handles, renderer) -> bool: - """Whether one column of these handles is shorter than the axes. +def legend_column_points(count: int) -> float: + """How tall one column naming this many things would stand. - A figure can name more values than its axes is tall, and the column - beside it then runs off the bottom of the page. + plot_lanes measures the legend it draws. A caller sizing a figure + before there is a figure to measure has only this. """ - figure = ax.get_figure() - scale = _MEASURED_DPI / figure.dpi + return count * plt.rcParams["font.size"] * 0.833 * _LEGEND_PITCH + + +def estimate_legend_rows(labels: Sequence, width_points: float) -> int: + """How many rows a legend naming these would take, laid out this wide. + + Also an estimate; see legend_column_points. + """ + labels = [str(x) for x in labels] + if not labels: + return 0 size = plt.rcParams["font.size"] * 0.833 - # Legend rows are set a little further apart than the text is tall. - pitch = _text_points("Ay", size)[1] * 1.9 - room = ax.get_window_extent(renderer).height * scale - return len(handles) * pitch <= room + widest = max(_text_points(x, size)[0] for x in labels) + columns = max(1, int(width_points // (widest + _SWATCH_WIDTH * size))) + return -(-len(labels) // columns) -def _legend_below(figure, ax, handles, renderer, outside): +def _legend_below(figure, ax, handles, owned): """Lay a legend out under the lanes, in as many columns as fit. How wide matplotlib draws a column is not worth predicting, so the widest layout is drawn and narrowed until it is inside the room it - has. Narrowing it further only makes it taller, so a legend which is - still too wide in one column is as close as column count can get. + has. Narrowing further only makes it taller, so a legend still too + wide in one column is as close as column count can get. """ - # Only a laid-out figure can be asked for room outside the axes; any - # other belongs to whoever built it, so the legend stays in the space - # the axes it was given already occupies. - room = figure.bbox.width if outside else ax.get_window_extent(renderer).width + # Asking a figure for room outside the axes moves every other axes on + # it, so it is only ever asked of a figure this call built. Any other + # belongs to its caller, and the legend takes the room of the one + # axes it was handed. + outside = owned and isinstance(figure.get_layout_engine(), ConstrainedLayoutEngine) + room = figure.bbox.width if outside else ax.get_window_extent().width columns = len(handles) while True: if outside: @@ -472,7 +490,7 @@ def _legend_below(figure, ax, handles, renderer, outside): fontsize="small", ) figure.draw_without_rendering() - box = legend.get_window_extent(renderer) + box = legend.get_window_extent() if columns == 1 or box.width <= room: break legend.remove() @@ -481,17 +499,19 @@ def _legend_below(figure, ax, handles, renderer, outside): if outside: return legend # The legend hangs off the foot of the axes, so the axes rises by what - # it took and the pair together cover what the axes covered before. - taken = box.height / figure.bbox.height + # the legend took and the two together cover what the axes did. Giving + # up more than half would leave less of the lanes than of the legend + # naming them, and a legend taller than that is one no axes this size + # can seat; it is drawn where it falls rather than pushing the lanes + # off the page to make room. position = ax.get_position() - ax.set_position( - ( - position.x0, - position.y0 + taken, - position.width, - max(position.height - taken, 0.1), - ) - ) + # Undo what an earlier call took, so drawing twice into one axes does + # not shrink it twice. + given = getattr(ax, "_dascore_legend_room", 0.0) + y_low, height = position.y0 - given, position.height + given + taken = min(box.height / figure.bbox.height, height / 2) + ax.set_position((position.x0, y_low + taken, position.width, height - taken)) + ax._dascore_legend_room = taken return legend @@ -514,6 +534,7 @@ def plot_lanes( x_label: str = "", lane_height: float = 0.8, colorbar_axes: Sequence[plt.Axes] | None = None, + manage_figure: bool = False, show: bool = False, ) -> plt.Axes: """ @@ -557,9 +578,10 @@ def plot_lanes( Whether overlapping intervals are packed into sub-rows. legend Whether to draw a legend and any colorbars. False, or "off", - draws neither; anything else draws what the colors earn. A - legend naming more values than the axes is tall is laid out in - columns below it rather than one column off the figure. + draws neither. "below" puts the legend under the lanes, in + columns, which is for a caller who sized the figure for it there. + Anything else draws what the colors earn, beside the lanes where + a column of them is shorter than the axes and below when not. max_labels Draw no text at all past this many intervals. x_limits @@ -572,6 +594,11 @@ def plot_lanes( The axes a colorbar takes its room from; the drawn axes alone by default. Pass every axes of a shared-x figure, or the others keep a width this one gives up. + manage_figure + Whether a legend too tall to sit beside the lanes may take its + room from the figure rather than from this axes. True only for a + caller which built the figure, since taking room from a figure + moves every other axes on it. Implied when ax is None. show Whether to call plt.show. @@ -602,6 +629,7 @@ def plot_lanes( f"{row['lane']!r} ends before it starts." ) raise ParameterError(msg) + owned = manage_figure or ax is None ax = _get_ax(ax) order = list(dict.fromkeys(frame["lane"])) if lanes is None else list(lanes) if lanes is not None and len(set(order)) != len(order): @@ -722,23 +750,28 @@ def plot_lanes( for name, color in legend_entries.items() ] figure = ax.get_figure() - figure.draw_without_rendering() - renderer = figure.canvas.get_renderer() - if _fits_beside(ax, handles, renderer): + below = legend == "below" + if not below: # A colorbar already occupies the strip beside the axes. offset = 1.01 + 0.17 * len(colorbars) - ax.legend( + beside = ax.legend( handles=handles, loc="upper left", bbox_to_anchor=(offset, 1.0), frameon=False, fontsize="small", ) - else: - # Only a constrained layout keeps room for a legend outside - # the axes; any other figure has to be given it explicitly. - outside = isinstance(figure.get_layout_engine(), ConstrainedLayoutEngine) - _legend_below(figure, ax, handles, renderer, outside) + figure.draw_without_rendering() + # One column beside the lanes is the natural home, but a + # figure can name more values than its axes is tall and the + # column then runs off the bottom of the page. Drawn and + # measured rather than predicted: how tall matplotlib sets + # its rows is its own affair. + below = beside.get_window_extent().height > ax.get_window_extent().height + if below: + beside.remove() + if below: + _legend_below(figure, ax, handles, owned) # Fit the labels last: the legend and the colorbars have taken their # room by now, so a label is measured against the box it lands in. _fit_labels(ax, placements, max_labels) diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index e03bfd275..8823198f6 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -435,17 +435,27 @@ def path( if ax is None: # A legend naming more than the lanes are tall sits below them, so # keep the rows it will take there; without them the lanes give up - # the room instead and every bar is squeezed into a sliver. How - # many columns it ends up in is the renderer's to decide, so this - # is an estimate, and one which is low costs only some of the room - # it was meant to save. - swatches = _legend_size(frame, color) + # the room instead and every bar is squeezed into a sliver. There + # is no figure to measure yet, so the rows are estimated from the + # labels themselves; an estimate which is low costs only some of + # the room it was meant to save. + named = _legend_names(frame, color) width = (figsize or (10.0, 0.0))[0] - per_row = max(1, int(width // 1.6)) - legend_rows = 0 if swatches <= len(lanes) else -(-swatches // per_row) + lane_height = 1.2 + 0.42 * len(lanes) + # A legend which would stand nearly as tall as the lanes it names + # reads better under them, and short of that it belongs at their + # side. Deciding here rather than leaving it to the renderer is + # what keeps the two from disagreeing: room kept below would + # otherwise be room enough to sit beside, and go unused. + column = _lanes.legend_column_points(len(named)) / 72.0 + legend_rows = ( + 0 + if column <= 0.8 * lane_height + else _lanes.estimate_legend_rows(named, 72.0 * width) + ) # Capped: a figure taller than a page is not more readable. height = min( - 1.2 + 0.42 * len(lanes) + 1.1 * len(columns) + 0.3 * legend_rows, + lane_height + 1.1 * len(columns) + 0.3 * legend_rows, 14.0, ) figure, all_axes = plt.subplots( @@ -460,7 +470,7 @@ def path( all_axes = all_axes[:, 0] ax, panels = all_axes[0], all_axes[1:] else: - figure, panels = None, [] + figure, panels, legend_rows = None, [], 0 pad = 0.02 * (limits[1] - limits[0]) plot_lanes( frame, @@ -475,6 +485,10 @@ def path( x_limits=(limits[0] - pad, limits[1] + pad), x_label="" if len(panels) else "Optical distance [m]", colorbar_axes=[ax, *panels] if len(panels) else None, + manage_figure=figure is not None, + # Room was kept below for a legend, so that is where it goes; + # letting it choose again would find the room and sit beside it. + legend="below" if legend_rows else True, ) named = [address, *([chosen.name] if chosen.name else []), _epoch_label(chosen)] ax.set_title(" ยท ".join(named), loc="left", fontsize="medium") @@ -526,25 +540,37 @@ def one(value): return low, high -def _legend_size(frame, color) -> int: - """How many swatches a legend of these lanes would name. +def _legend_names(frame, color) -> list[str]: + """What a legend of these lanes would name, in the order it names it. - Only what earns one counts: a lane of numbers earns a colorbar and - reads from that, one color for every lane earns no legend at all, - and a lane which states no value earns one swatch named for itself. - A mapping names whatever it holds, numbers included. + Only what earns a swatch counts. One color for every lane earns no + legend at all; a lane which states no value earns one swatch named + for the lane; a lane of numbers reads from a colorbar instead, unless + a mapping gives its values swatches, in which case it names the ones + the mapping holds and no others. """ if isinstance(color, str): - return 0 - keyed = set() + return [] + flat, keyed = {}, {} if isinstance(color, Mapping): for name, entry in color.items(): # Keyed by lane it holds a mapping of values; keyed by value # the key is the value itself. - keyed.update(entry if isinstance(entry, Mapping) else {name}) - stated = frame["value"] - named = stated[[isinstance(x, str) or x in keyed for x in stated]] - return int(named.nunique() + frame.loc[stated.isna(), "lane"].nunique()) + if isinstance(entry, Mapping): + keyed[name] = entry + else: + flat[name] = entry + out = [] + for lane, rows in frame.groupby("lane", sort=False): + values = list(dict.fromkeys(rows["value"])) + mapping = keyed.get(lane) or flat + if mapping: + out.extend(str(x) for x in values if x in mapping) + elif all(pd.isnull(x) for x in values): + out.append(str(lane)) + else: + out.extend(str(x) for x in values if isinstance(x, str) and x) + return list(dict.fromkeys(out)) def _lane_colors(color): diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index aa4dca6a7..31e12a93b 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -17,7 +17,7 @@ from dascore.viz.inventory import ( COMPONENT_COLORS, _distance_window, - _legend_size, + _legend_names, map_path, path, timeline, @@ -381,6 +381,20 @@ def test_all_tracks(self, site): # Components take their fixed colors, so the legend names the types. assert "FiberSegment" in _legend_labels(ax) + def test_a_narrow_figure_keeps_its_legend_on_the_page(self, site): + """A figure too small to seat a legend must not be made nonsense of. + + The lanes keep some of the figure whatever the legend needs, and + neither they nor it leave the canvas. + """ + crowded = build_labeled_inventory(24, site.coordinate_reference_system) + ax = path(crowded, "DAS.L2.00", figsize=(3.0, 2.0)) + figure = ax.get_figure() + figure.draw_without_rendering() + lanes = ax.get_window_extent() + assert lanes.height > 0 and lanes.y0 >= 0 + assert lanes.y1 <= figure.bbox.height + def test_room_is_kept_for_a_legend_which_names_many_values(self, site): """A path naming more values than it has lanes needs a taller figure. @@ -399,26 +413,40 @@ def test_room_is_kept_for_a_legend_which_names_many_values(self, site): # Same one lane either way, so only the legend can move the height. assert _lanes(many) == lanes == ["components", "hole"] assert figure.get_size_inches()[1] > short - # The room is kept for the legend, not taken from the lanes. - assert many.get_window_extent().height / figure.dpi >= room + # The room is kept for the legend, not taken from the lanes: with + # no allowance at all these lanes lose a third of their height. + assert many.get_window_extent().height / figure.dpi >= room * 0.9 box = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) assert box.y0 >= 0 and box.y1 <= figure.bbox.height - def test_a_mapping_names_its_numbers_too(self, site): - """A number is a colorbar until a mapping gives it a swatch.""" + def test_a_mapping_names_its_numbers_and_only_those(self, site): + """A number is a colorbar until a mapping gives it a swatch. + + A mapping which names some of them names only those, which is + what the legend beside them will show. + """ frame = pd.DataFrame( {"lane": ["count"] * 3, "value": [0, 1, 2], "start": 0.0, "end": 1.0} ) - assert _legend_size(frame, None) == 0 - assert _legend_size(frame, "red") == 0 - assert _legend_size(frame, {0: "red", 1: "blue", 2: "green"}) == 3 - assert _legend_size(frame, {"count": {0: "red", 1: "blue"}}) == 2 + assert _legend_names(frame, None) == [] + assert _legend_names(frame, "red") == [] + assert _legend_names(frame, {0: "red", 1: "blue", 2: "green"}) == [ + "0", + "1", + "2", + ] + assert _legend_names(frame, {"count": {0: "red"}}) == ["0"] - def test_a_narrow_figure_still_sizes_its_legend(self, site): - """The columns a legend is divided into cannot fall to none.""" + def test_a_flat_mapping_names_only_what_it_holds(self, site): + """Room is kept for the swatches drawn, not for every value.""" crowded = build_labeled_inventory(24, site.coordinate_reference_system) - ax = path(crowded, "DAS.L2.00", figsize=(1.0, 2.0)) - assert ax.get_figure().get_size_inches()[0] == 1.0 + frame = pd.DataFrame( + {"lane": ["hole"] * 3, "value": ["a", "b", "c"], "start": 0.0, "end": 1.0} + ) + assert _legend_names(frame, {"a": "red"}) == ["a"] + # And the figure is no taller for the values it does not name. + one = path(crowded, "DAS.L2.00", color={"H00": "red"}) + assert one.get_figure().get_size_inches()[1] < 3.0 def test_one_color_for_every_lane_needs_no_legend_room(self, site): """A figure which names no value has no legend to keep room for.""" diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index 69766297a..fb9ab1fb4 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -9,13 +9,18 @@ import numpy as np import pandas as pd import pytest +from matplotlib.backends.backend_pdf import FigureCanvasPdf from matplotlib.collections import PatchCollection +from matplotlib.figure import Figure from dascore.exceptions import ParameterError from dascore.viz._lanes import ( + _LABEL_PAD, UNCOVERED_COLOR, WHEEL_ORDER, _pack_rows, + _text_points, + estimate_legend_rows, plot_lanes, ) @@ -299,16 +304,30 @@ def test_a_narrow_box_turns_its_label(self): assert sorted(_texts(ax)) == ["alpha zone", "beta zone"] assert {x.get_rotation() for x in ax.texts} == {90.0} - def test_a_label_needs_clearance(self): - """A label the exact width of its box would touch the next one.""" - frame = pd.DataFrame({"start": [0.0], "end": [1.0], "v": ["tight"]}) - _, ax = plt.subplots(figsize=(4, 4)) - plot_lanes(frame, ax=ax, value="v") - figure = ax.get_figure() + @pytest.mark.parametrize("slack,rotation", [(1.0, 90.0), (_LABEL_PAD + 1.0, 0.0)]) + def test_a_label_needs_clearance(self, slack, rotation): + """A label the exact width of its box would touch the next one. + + A box wider than the text but by less than the clearance is + refused the flat label it would otherwise take, which is what + keeps two labels in neighboring boxes from reading as one word. + """ + text = "value" + needed = _text_points(text, plt.rcParams["font.size"] * 0.8)[0] + figure, ax = plt.subplots(figsize=(4, 2), dpi=100) figure.draw_without_rendering() - width = ax.texts[0].get_window_extent(figure.canvas.get_renderer()).width - box = ax.get_window_extent().width / 1.04 # the frame plus its padding - assert width <= box - 4.0 + # Scale the axes so one data unit is exactly the room to test. + points = ax.get_window_extent().width * 72 / figure.dpi + plt.close(figure) + _, ax = plt.subplots(figsize=(4, 2), dpi=100) + plot_lanes( + pd.DataFrame({"start": [0.0], "end": [1.0], "v": [text]}), + ax=ax, + value="v", + x_limits=(0.0, points / (needed + slack)), + ) + assert _texts(ax) == [text] + assert ax.texts[0].get_rotation() == rotation def test_no_drawn_label_overflows_its_box(self): """Every label kept is measured against the axes it lands in. @@ -325,8 +344,33 @@ def test_no_drawn_label_overflows_its_box(self): } ) ax = plot_lanes(frame, value="v") + # Every label kept sits inside its box -- and they were kept. The + # first half of that is true of a figure which drew none at all. + assert len(ax.texts) == len(frame) assert _overflowing(ax) == [] + @pytest.mark.parametrize("text", [" ", " ", "two\nlines", "$x^2$"]) + def test_labels_matplotlib_lays_out_its_own_way(self, text): + """Whitespace, several lines and mathtext are all measurable. + + A label is measured before it is drawn, so a string the measurer + cannot read would take the whole figure down with it. + """ + frame = pd.DataFrame({"start": [0.0], "end": [10.0], "v": [text]}) + ax = plot_lanes(frame, value="v") + assert _overflowing(ax) == [] + + def test_a_legend_naming_nothing_takes_no_rows(self): + """A caller sizing a figure for no legend keeps no room for one.""" + assert estimate_legend_rows([], 720.0) == 0 + assert estimate_legend_rows(["one"], 720.0) == 1 + + def test_a_label_of_two_lines_is_two_lines_tall(self): + """Height is what decides a rotated label, so lines must count.""" + size = plt.rcParams["font.size"] * 0.8 + one = _text_points("two", size)[1] + assert _text_points("two\nlines", size)[1] > 2 * one + def test_max_labels(self): """Past max_labels no text is drawn at all.""" frame = pd.DataFrame({"start": [0.0, 50.0], "end": [50.0, 100.0]}) @@ -610,10 +654,12 @@ def test_a_legend_below_stays_inside_the_axes_it_was_given(self): plot_lanes(frame, ax=ax, value="v") figure.draw_without_rendering() box = _legend_of(figure, ax).get_window_extent(figure.canvas.get_renderer()) - # Under the lanes, and no lower than the lanes used to reach. + # Under the lanes, clear of the neighbor, and neither the lanes + # nor the legend pushed off the page to make room. assert box.y1 <= ax.get_window_extent().y0 + 1 - assert box.y0 >= before.y0 - 1 assert box.y0 >= below.get_window_extent().y1 + assert ax.get_position().y0 >= 0 + assert ax.get_window_extent().height >= before.height / 2 - 1 def test_a_short_legend_stays_beside_them(self, string_frame): """Few enough values still read best in one column at the side.""" @@ -622,6 +668,19 @@ def test_a_short_legend_stays_beside_them(self, string_frame): box = ax.get_legend().get_window_extent() assert box.x0 >= ax.get_window_extent().x1 + def test_a_backend_which_renders_no_pixels(self, string_frame): + """Not every canvas hands out a renderer when asked for one. + + A vector backend has none until it draws, so a figure bound for + a pdf must be laid out without asking the canvas for one. + """ + figure = Figure(figsize=(4, 3), layout="constrained") + FigureCanvasPdf(figure) + ax = figure.subplots() + names, frame = _many_values() + plot_lanes(frame, ax=ax, value="v") + assert len(_legend_of(figure, ax).get_texts()) == len(names) + def test_legend_off(self, string_frame): """legend=False draws none.""" ax = plot_lanes(string_frame, lane="group", value="value", legend=False) From 1cc02ce7d85f6588dcbd86386056dc3ba4cf8a88 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 23:06:06 +0200 Subject: [PATCH 6/7] Say each thing once, and in the place it is decided Prose review of the four commits before this one. The dpi-rounding argument was written out twice, once at the constant and once where the measuring happens; so was the reason labels are fitted last. Each is now where it is acted on and nowhere else. Two docstrings had drifted from the code. path() promised a label too wide for its box would be turned on its side "rather than dropped", but rotation is a second chance before dropping, not instead of it; and _legend_names claimed a lane of numbers always earns a colorbar, when a handful of them are read off the boxes they are printed in. The rest is naming. "Crossing the wheel" read as wrapping around it, which is what the branch below exists to avoid. "The one rule" had no referent. And 0.833, which is what matplotlib makes of the "small" the legends ask for, is a constant now rather than a number appearing twice with no account of itself. --- dascore/viz/_lanes.py | 27 ++++++++++++++------------- dascore/viz/inventory.py | 29 +++++++++++++++-------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 498062488..e1106d9ad 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -47,17 +47,17 @@ # and alternating shade separates two which still land on a similar hue. _GOLDEN_STEP = 0.6180339887498949 -# Everything a label is measured in, so the same figure keeps the same -# labels whatever resolution it is drawn at. Rasterized glyphs round to -# whole pixels, which makes text as much as a tenth wider at 50 dpi than -# at 300, so both the text and the box it must fit are asked for in -# points instead of the pixels the renderer works in. +# Points per inch: the unit both a label and its box are measured in, so +# the same figure keeps the same labels whatever dpi it is drawn at. _MEASURED_DPI = 72.0 # What matplotlib sets successive lines of one label apart by, as a # multiple of the font size. _LINE_SPACING = 1.2 +# What matplotlib makes of the fontsize="small" the legends ask for. +_SMALL_SCALE = 0.833 + # A legend entry is its label plus a swatch and the gaps around it, which # come to about this many times the text height. _SWATCH_WIDTH = 3.0 @@ -226,7 +226,8 @@ def _string_colors(frame, vocabulary=None, cmap_name=STRING_CMAP) -> dict: The vocabulary widens the palette beyond what this frame holds, so a figure of part of a subject colors it as a figure of all of it does. Adding a value to the vocabulary itself moves the colors of the ones - which sort after it, and crossing the wheel moves all of them. + which sort after it, and pushing the count past the wheel moves all + of them. """ seen = list(frame["value"].tolist()) + list(vocabulary or []) values = sorted({x for x in seen if isinstance(x, str) and x != ""}) @@ -389,16 +390,16 @@ def _fit_labels(ax, placements, max_labels): """Draw each label the way it fits its box, and drop what cannot. Horizontal reads best, so it is tried first. A lane of many short - stretches gives every box far less width than its text needs, and - turning the text on its side fits it where the one rule would drop - it and leave the lane readable only from the legend. + stretches gives every box far less width than its text needs; + turning the text on its side keeps those labels, which fitting + horizontally alone would drop and leave readable only from the + legend. """ if len(placements) > max_labels: return figure = ax.get_figure() # Lay the figure out before measuring: a label is compared against its - # box, and the box moves when the axes does. The legend and the - # colorbars are drawn by now, so this is the geometry it lands in. + # box, and the box moves when the axes does. figure.draw_without_rendering() transform = ax.transData scale = _MEASURED_DPI / figure.dpi @@ -438,7 +439,7 @@ def legend_column_points(count: int) -> float: plot_lanes measures the legend it draws. A caller sizing a figure before there is a figure to measure has only this. """ - return count * plt.rcParams["font.size"] * 0.833 * _LEGEND_PITCH + return count * plt.rcParams["font.size"] * _SMALL_SCALE * _LEGEND_PITCH def estimate_legend_rows(labels: Sequence, width_points: float) -> int: @@ -449,7 +450,7 @@ def estimate_legend_rows(labels: Sequence, width_points: float) -> int: labels = [str(x) for x in labels] if not labels: return 0 - size = plt.rcParams["font.size"] * 0.833 + size = plt.rcParams["font.size"] * _SMALL_SCALE widest = max(_text_points(x, size)[0] for x in labels) columns = max(1, int(width_points // (widest + _SWATCH_WIDTH * size))) return -(-len(labels) // columns) diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index 8823198f6..cd3ccf858 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -387,9 +387,9 @@ def path( color Passed to the lane renderer to override its colors. max_labels - Draw no lane text at all past this many intervals. Under it, a - label too wide for its box is turned on its side rather than - dropped, so a lane of many short stretches still reads. + Draw no lane text at all past this many intervals. Below that + count, a label too wide for its box is turned on its side, and + dropped only if it does not fit that way either. ax An Axes to draw the lanes on. Column panels need their own figure, so passing this and naming columns is refused. @@ -433,12 +433,12 @@ def path( frame = _select_tracks(frame, tracks, chosen) lanes = list(dict.fromkeys(frame["lane"])) if ax is None: - # A legend naming more than the lanes are tall sits below them, so - # keep the rows it will take there; without them the lanes give up - # the room instead and every bar is squeezed into a sliver. There - # is no figure to measure yet, so the rows are estimated from the - # labels themselves; an estimate which is low costs only some of - # the room it was meant to save. + # A legend which goes below the lanes needs height kept for it; + # without that the lanes give up the room instead and every bar is + # squeezed into a sliver. There is no figure to measure yet, so + # both the standing height of one column and the rows it breaks + # into are estimated from the labels; guessing low gives back less + # room than intended, which is the harmless direction. named = _legend_names(frame, color) width = (figsize or (10.0, 0.0))[0] lane_height = 1.2 + 0.42 * len(lanes) @@ -543,11 +543,12 @@ def one(value): def _legend_names(frame, color) -> list[str]: """What a legend of these lanes would name, in the order it names it. - Only what earns a swatch counts. One color for every lane earns no - legend at all; a lane which states no value earns one swatch named - for the lane; a lane of numbers reads from a colorbar instead, unless - a mapping gives its values swatches, in which case it names the ones - the mapping holds and no others. + Only what earns a swatch counts. A single color for the whole figure + earns no legend at all; a lane which states no value earns one swatch + named for the lane; and a lane of numbers reads from its colorbar, or + from the numbers printed in its boxes where there are few enough of + them, so it names none. A mapping is the exception: it gives swatches + to the values it holds, numbers included, and to no others. """ if isinstance(color, str): return [] From ac1feb00db9ad4adfbc45146ce9589024a1febeb Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Sat, 22 Aug 2026 23:24:23 +0200 Subject: [PATCH 7/7] Count the lines a legend names, not the entries A value written on two lines stands two lines tall in the legend, but the height path() kept for one counted entries. A path whose labels carry a newline reserved half what its legend needed, and the legend then went below into room nobody had kept, taking it from the lanes. Both estimates count lines now: the standing height of one column, and the rows it breaks into at a given width, where a row is as tall as its tallest entry. --- dascore/viz/_lanes.py | 16 ++++++++++++---- dascore/viz/inventory.py | 2 +- tests/test_viz/test_inventory_viz.py | 21 +++++++++++++++++++-- tests/test_viz/test_lanes.py | 10 ++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index e1106d9ad..3dc8b8e12 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -433,13 +433,19 @@ def _fit_labels(ax, placements, max_labels): break -def legend_column_points(count: int) -> float: - """How tall one column naming this many things would stand. +def _label_lines(labels: Sequence) -> list[int]: + """How many lines each of these labels is written on.""" + return [str(x).count("\n") + 1 for x in labels] + + +def legend_column_points(labels: Sequence) -> float: + """How tall one column naming these would stand. plot_lanes measures the legend it draws. A caller sizing a figure before there is a figure to measure has only this. """ - return count * plt.rcParams["font.size"] * _SMALL_SCALE * _LEGEND_PITCH + pitch = plt.rcParams["font.size"] * _SMALL_SCALE * _LEGEND_PITCH + return sum(_label_lines(labels)) * pitch def estimate_legend_rows(labels: Sequence, width_points: float) -> int: @@ -453,7 +459,9 @@ def estimate_legend_rows(labels: Sequence, width_points: float) -> int: size = plt.rcParams["font.size"] * _SMALL_SCALE widest = max(_text_points(x, size)[0] for x in labels) columns = max(1, int(width_points // (widest + _SWATCH_WIDTH * size))) - return -(-len(labels) // columns) + # Counted in single lines, since that is what a caller keeping room + # for them counts in; a row is as tall as its tallest entry. + return -(-len(labels) // columns) * max(_label_lines(labels)) def _legend_below(figure, ax, handles, owned): diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index cd3ccf858..f300dde42 100644 --- a/dascore/viz/inventory.py +++ b/dascore/viz/inventory.py @@ -447,7 +447,7 @@ def path( # side. Deciding here rather than leaving it to the renderer is # what keeps the two from disagreeing: room kept below would # otherwise be room enough to sit beside, and go unused. - column = _lanes.legend_column_points(len(named)) / 72.0 + column = _lanes.legend_column_points(named) / 72.0 legend_rows = ( 0 if column <= 0.8 * lane_height diff --git a/tests/test_viz/test_inventory_viz.py b/tests/test_viz/test_inventory_viz.py index 31e12a93b..0ed5b2025 100644 --- a/tests/test_viz/test_inventory_viz.py +++ b/tests/test_viz/test_inventory_viz.py @@ -169,14 +169,14 @@ def build_site_inventory() -> inv.Inventory: ).check() -def build_labeled_inventory(values: int, crs) -> inv.Inventory: +def build_labeled_inventory(values: int, crs, lines: int = 1) -> inv.Inventory: """One path of one label group, stating this many distinct values.""" labels = tuple( inv.OpticalPathLabel( start_distance=float(x * 10), end_distance=float(x * 10 + 8), group="hole", - value=f"H{x % values:02d}", + value="\n".join([f"H{x % values:02d}"] * lines), ) for x in range(24) ) @@ -395,6 +395,23 @@ def test_a_narrow_figure_keeps_its_legend_on_the_page(self, site): assert lanes.height > 0 and lanes.y0 >= 0 assert lanes.y1 <= figure.bbox.height + def test_a_value_on_two_lines_is_kept_room_for_both(self, site): + """A legend entry of two lines stands as tall as two of one. + + Counting entries and not lines would keep too little room, and + the legend would go below into space nobody reserved. + """ + crs = site.coordinate_reference_system + flat = path(build_labeled_inventory(12, crs), "DAS.L2.00") + short = flat.get_figure().get_size_inches()[1] + plt.close("all") + tall = path(build_labeled_inventory(12, crs, lines=2), "DAS.L2.00") + figure = tall.get_figure() + assert figure.get_size_inches()[1] > short + figure.draw_without_rendering() + box = figure.legends[0].get_window_extent(figure.canvas.get_renderer()) + assert box.y0 >= 0 and box.y1 <= tall.get_window_extent().y0 + def test_room_is_kept_for_a_legend_which_names_many_values(self, site): """A path naming more values than it has lanes needs a taller figure. diff --git a/tests/test_viz/test_lanes.py b/tests/test_viz/test_lanes.py index fb9ab1fb4..1ebc3855b 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -21,6 +21,7 @@ _pack_rows, _text_points, estimate_legend_rows, + legend_column_points, plot_lanes, ) @@ -365,6 +366,15 @@ def test_a_legend_naming_nothing_takes_no_rows(self): assert estimate_legend_rows([], 720.0) == 0 assert estimate_legend_rows(["one"], 720.0) == 1 + def test_a_legend_estimate_counts_the_lines_it_names(self): + """A value written on two lines takes two lines of legend. + + Counting entries rather than lines keeps too little room, and + the legend then goes below into space nobody reserved. + """ + assert legend_column_points(["a\nb"]) == legend_column_points(["a", "b"]) + assert estimate_legend_rows(["a\nb"], 720.0) == 2 + def test_a_label_of_two_lines_is_two_lines_tall(self): """Height is what decides a rotated label, so lines must count.""" size = plt.rcParams["font.size"] * 0.8