diff --git a/dascore/viz/_lanes.py b/dascore/viz/_lanes.py index 666e980c0..3dc8b8e12 100644 --- a/dascore/viz/_lanes.py +++ b/dascore/viz/_lanes.py @@ -9,9 +9,11 @@ from __future__ import annotations +import colorsys import datetime from collections.abc import Mapping, Sequence +import matplotlib.cbook as cbook import matplotlib.dates as mdates import matplotlib.patheffects as pe import matplotlib.pyplot as plt @@ -19,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 text_to_path from dascore.exceptions import ParameterError from dascore.utils.intervals import normalize_value, value_kind @@ -36,6 +41,36 @@ 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 + +# 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 + +# 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. +_LABEL_PAD = 3.0 + # 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,14 +197,44 @@ 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. + + 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 + # Held near tab20's own saturation so the two schemes sit together + # in a figure whose other lanes are still colored from the wheel. + saturation, brightness = _SHADES[index % len(_SHADES)] + out[value] = (*colorsys.hsv_to_rgb(hue, saturation, brightness), 1.0) + return out + + def _string_colors(frame, vocabulary=None, cmap_name=STRING_CMAP) -> dict: """Map every string value to a stable color. 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 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 != ""}) + 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)]) @@ -291,35 +356,172 @@ def _draw_open_edges(ax, rows, y_low, height, colors, span): ax.add_collection(patches) +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]) * scale, abs(high[1] - low[1]) * scale + + +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, 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. + """ + 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): - """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; + 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 in pixels, and both move when the axes does. + # box, and the box moves when the axes does. figure.draw_without_rendering() - renderer = figure.canvas.get_renderer() transform = ax.transData - for text, x_mid, y_mid, width in placements: + 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 - 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): - artist.remove() + 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): + # 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=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")], + ) + break + + +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. + """ + 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: + """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"] * _SMALL_SCALE + widest = max(_text_points(x, size)[0] for x in labels) + columns = max(1, int(width_points // (widest + _SWATCH_WIDTH * size))) + # 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): + """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 further only makes it taller, so a legend still too + wide in one column is as close as column count can get. + """ + # 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: + legend = figure.legend( + handles=handles, + loc="outside lower center", + ncol=columns, + frameon=False, + fontsize="small", + ) + else: + legend = ax.legend( + handles=handles, + 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() + 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 * room / box.width))) + if outside: + return legend + # The legend hangs off the foot of the axes, so the axes rises by what + # 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() + # 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 def plot_lanes( @@ -341,6 +543,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: """ @@ -366,7 +569,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. @@ -382,7 +587,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. + 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 @@ -395,6 +603,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. @@ -425,6 +638,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): @@ -486,7 +700,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( @@ -524,7 +744,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( @@ -539,15 +758,32 @@ 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() + below = legend == "below" + if not below: + # A colorbar already occupies the strip beside the axes. + offset = 1.01 + 0.17 * len(colorbars) + beside = ax.legend( + handles=handles, + loc="upper left", + bbox_to_anchor=(offset, 1.0), + frameon=False, + fontsize="small", + ) + 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) if show: plt.show() return ax diff --git a/dascore/viz/inventory.py b/dascore/viz/inventory.py index d2fbbbeba..f300dde42 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. 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. @@ -431,8 +433,31 @@ def path( frame = _select_tracks(frame, tracks, chosen) lanes = list(dict.fromkeys(frame["lane"])) if ax is None: + # 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) + # 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(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), 14.0) + height = min( + lane_height + 1.1 * len(columns) + 0.3 * legend_rows, + 14.0, + ) figure, all_axes = plt.subplots( 1 + len(columns), 1, @@ -445,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, @@ -460,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") @@ -511,6 +540,40 @@ def one(value): return low, high +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. 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 [] + 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. + 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): """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 c09cdd7d5..0ed5b2025 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_names, map_path, path, timeline, @@ -167,6 +169,42 @@ def build_site_inventory() -> inv.Inventory: ).check() +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="\n".join([f"H{x % values:02d}"] * lines), + ) + 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 +381,100 @@ 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_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. + + 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") + 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 == ["components", "hole"] + assert figure.get_size_inches()[1] > short + # 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_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_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_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) + 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.""" + 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 8305c6a88..1ebc3855b 100644 --- a/tests/test_viz/test_lanes.py +++ b/tests/test_viz/test_lanes.py @@ -9,10 +9,21 @@ 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 UNCOVERED_COLOR, _pack_rows, plot_lanes +from dascore.viz._lanes import ( + _LABEL_PAD, + UNCOVERED_COLOR, + WHEEL_ORDER, + _pack_rows, + _text_points, + estimate_legend_rows, + legend_column_points, + plot_lanes, +) def _collections(ax): @@ -34,6 +45,45 @@ 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() + 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.""" @@ -240,6 +290,97 @@ 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} + + @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() + # 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. + + 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") + # 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_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 + 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]}) @@ -440,21 +581,115 @@ def test_vocabulary_widens_the_palette(self, string_frame): _collections(shifted)[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.""" + 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": [0.0], "end": [1.0], "v": ["a rather long label"]} + { + "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]) + + @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. + + Only a constrained layout keeps room for a legend outside the + axes, so the other figures have to be given it explicitly. + """ + names, frame = _many_values() + figure, ax = plt.subplots(figsize=(8, 3), layout=engine) + plot_lanes(frame, ax=ax, value="v") + 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 + assert box.y0 >= 0 and box.y1 <= figure.bbox.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_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() + box = _legend_of(figure, ax).get_window_extent(figure.canvas.get_renderer()) + # 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 >= 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.""" + 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_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."""