"""Small-multiple heatmaps for plate-layout measurements.
All plates for one measurement share a color scale and are arranged in a
compact grid with square wells. Missing wells remain distinct from measured
zeros and are excluded from color-limit estimation. Styling is scoped through
:func:`spacr.figures.style.figure_style` so drawing a plate does not change
global Matplotlib settings.
"""
from __future__ import annotations
import math
from typing import List, Optional, Sequence, Tuple
import numpy as np
from .panels import Panel
from .style import (TYPE_SCALE, WEIGHTS, Palette, figure_style, resolve_ink,
theme_target)
#: The composite the small multiple aims at. Slightly wider than square
#: because the colour bar takes a strip along the bottom; a tile in the
#: figure grid is square-ish, so the closer the composite is to 1 the more
#: of the tile the plates get.
TARGET_ASPECT = 1.2
#: Figure width in inches. The double-column measure the sheet uses, so a
#: plate panel and a regression sheet are the same width on a page.
WIDTH = 7.0
#: Margins, in inches, measured from what the type actually needs: the row
#: letters on the left, the column numbers and the colour bar below, the
#: plate name above.
#: The gaps are small because the labels are SHARED: only the left column
#: carries row letters and only the bottom row carries column numbers, so
#: nothing has to fit between two plates except the lower one's name. A
#: small multiple should read as one block, not as four pictures that
#: happen to be near each other.
MARGIN = {"left": 0.28, "right": 0.10, "top": 0.20, "bottom": 0.58,
"wspace": 0.18, "hspace": 0.22}
#: Where the shared colour bar sits, in inches from the bottom of the
#: figure, and how thick it is. Below it go its own tick labels and the
#: measurement's name, which is why it is not at the very bottom.
BAR = {"bottom": 0.24, "height": 0.075, "width": 2.4}
#: How dark the "no measurement here" wash is, as a fraction of the ink.
#: Neutral, so an empty well differs from a measured one in HUE and not
#: only in lightness -- a difference that survives a bad monitor and a
#: greyscale print, which a lightness-only difference does not.
EMPTY_WASH_ALPHA = 0.09
[docs]
def plate_ramp(target: str = "screen"):
"""Create the sequential blue color map used for plate measurements.
Parameters
----------
target : str, default="screen"
``"print"`` selects the print ramp. Every other value selects the
screen ramp, which avoids the darkest print color so high values
remain distinct from a dark interface background.
Returns
-------
matplotlib.colors.Colormap
Color map with a transparent bad-value color for unmeasured wells.
"""
from matplotlib.colors import LinearSegmentedColormap
if target == "print":
stops = ["#F7FAFD", Palette.BLUE_LIGHT, Palette.BLUE, Palette.NAVY]
else:
stops = ["#E8EDEE", Palette.BLUE_LIGHT, Palette.BLUE]
ramp = LinearSegmentedColormap.from_list(f"spacr_plate_{target}", stops)
ramp.set_bad("none")
return ramp
[docs]
def score_ramp(target: str = "screen"):
"""Create the diverging color map used for signed plate scores.
A hit score (SSMD, robust z, B-score) is signed about the negative
control, which is what a diverging map is for. The ends are the house
``down`` and ``up`` colours (:data:`spacr.figures.style.ROLES`), so a
well scored below the control reads rust and one above it green, the
same as on a volcano.
:param target: ``"print"`` centres on a near-white; every other value on
the light grey of the screen plate ramp.
:returns: A color map with a transparent bad-value color.
"""
from matplotlib.colors import LinearSegmentedColormap
middle = "#F7F7F7" if target == "print" else "#E8EDEE"
ramp = LinearSegmentedColormap.from_list(
f"spacr_score_{target}", [Palette.RUST, Palette.CORAL, middle,
"#8FC2A2", Palette.GREEN])
ramp.set_bad("none")
return ramp
[docs]
def plate_names(frame) -> List[str]:
"""Return distinct nonempty plate identifiers in first-occurrence order.
``prc`` keys are parsed from the right: the final tokens are row and
column, while every preceding token belongs to the plate identifier.
This preserves plate identifiers that contain underscores.
Screens whose keys are the plain three-token form -- which is every one
this module has been run on -- are unaffected: the plate is still the
first token because there is nothing in front of it.
:param frame: long-format table whose ``prc`` values identify wells.
:returns: Plate identifiers parsed from the right. Keys with fewer than
three underscore-delimited tokens are retained verbatim.
"""
if "prc" not in getattr(frame, "columns", ()):
return []
seen, order = set(), []
for key in frame["prc"].astype(str):
parts = key.split("_")
if len(parts) < 3:
name = key
else:
name = "_".join(parts[:-2])
if name and name not in seen:
seen.add(name)
order.append(name)
return order
[docs]
def full_plate_grid(rows: Sequence[int], columns: Sequence[int]) -> Tuple[int, int]:
"""The plate the measured wells sit on, not the box that bounds them.
THE EDGE HAS TO BE THE EDGE. Pivoting only the wells that carry data
drops an entirely unused column, so a screen that never used columns 1-3
-- which the tsg101 screen does not -- drew its first measured column
hard against the left spine. Every edge effect then reads one plate
position out, and an edge effect is the artefact a plate heatmap exists
to show.
:param rows: measured 1-based plate row indices.
:param columns: measured 1-based plate column indices.
:returns: ``(n_rows, n_columns)`` of the smallest standard format that
contains every measured well, or the bounding box when the wells fit
no standard plate (a partial or non-standard layout).
"""
from .. import schema as _schema
if not len(rows) or not len(columns):
return 0, 0
top, right = int(max(rows)), int(max(columns))
fmt = _schema.plate_format_for(top, right)
if fmt is None:
return top, right
n_rows, n_columns = _schema.PLATE_FORMATS[fmt]
return int(n_rows), int(n_columns)
[docs]
def well_matrices(frame, variable: str, *, grouping: str = "mean",
min_count=0, plates: Optional[Sequence[str]] = None):
"""One matrix per plate, on a shared grid, with absent wells as ``nan``.
Wraps :func:`spacr.plot.generate_plate_heatmap` -- which is where the
prc parsing, the letter walk past row P and the min_count filter live --
and undoes the one thing it does that a picture must not inherit: its
``.fillna(0)``. A well with no rows and a well that measured zero are
the same cell afterwards, so the count map is fetched alongside the
value map and a well with a count of zero is masked back out.
A well with rows but nothing numeric in any of them is masked too. The
count map counts ROWS, and the aggregation coerces the variable with
``errors='coerce'``, so such a well aggregates to NaN and is filled with
the same invented zero one step further in.
:param frame: long-format frame with a ``prc`` column.
:param variable: the measurement column to aggregate.
:param grouping: ``'mean'``, ``'sum'`` or ``'count'``.
:param min_count: wells with fewer rows than this are dropped, and then
read as absent rather than as zero.
:param plates: return only these plates, in this order.
:returns: ``(names, matrices, (n_rows, n_columns))`` with equally many
names and matrices on the smallest fitting standard plate grid.
Absent or unreadable wells and wells below ``min_count`` are ``nan``.
"""
import pandas as pd
from ..plot import generate_plate_heatmap
from .. import schema as _schema
names = list(plates) if plates is not None else plate_names(frame)
if not names:
return [], [], (0, 0)
wanted = ["prc"] + ([variable] if variable in frame.columns else [])
work = frame.loc[:, wanted].copy()
text = work["prc"].astype(str)
head = None
if text.str.count(_schema.KEY_SEPARATOR).ge(2).all():
head = text.str.rsplit(_schema.KEY_SEPARATOR, n=2).str[0].to_numpy()
readable = None
if grouping != "count" and variable in work.columns:
numeric = pd.to_numeric(work[variable], errors="coerce").notna().to_numpy()
if not numeric.all():
readable = numeric
maps, counts = [], []
for name in names:
on_plate = None if head is None else head == str(name)
subset = work if on_plate is None else work[on_plate].copy()
values, _limits = generate_plate_heatmap(
subset, name, variable, grouping, "all", min_count)
if grouping == "count":
present = values
else:
present = generate_plate_heatmap(
subset, name, variable, "count", "all", min_count)[0]
if readable is not None:
present = _drop_unreadable_wells(
generate_plate_heatmap, present, subset,
readable if on_plate is None else readable[on_plate],
name, variable)
maps.append(values)
counts.append(present)
rows = sorted({index for m in maps for index in
(_schema.row_index(label) for label in m.index)
if index is not None})
columns = sorted({index for m in maps for index in
(_schema.column_index(label) for label in m.columns)
if index is not None})
n_rows, n_columns = full_plate_grid(rows, columns)
if not n_rows or not n_columns:
return names, [], (0, 0)
row_ids = [_schema.row_id(i) for i in range(1, n_rows + 1)]
column_ids = [_schema.column_id(i) for i in range(1, n_columns + 1)]
matrices = []
for values, present in zip(maps, counts):
grid = values.reindex(index=row_ids, columns=column_ids)
seen = present.reindex(index=row_ids, columns=column_ids)
block = grid.to_numpy(dtype="float64", copy=True)
empty = ~(seen.to_numpy(dtype="float64") > 0)
block[empty] = np.nan
matrices.append(block)
return names, matrices, (n_rows, n_columns)
def _drop_unreadable_wells(heatmap, present, subset, readable, name, variable):
"""Zero the presence of a well that has rows but no number in any of them.
``min_count`` KEEPS ITS MEANING -- it counts rows, as it always has, so
the readable-row map is taken at ``min_count`` 0 and used only to knock
out the wells that have nothing numeric at all. A well that is half
unreadable is still the mean of the half that is readable, which is what
``generate_plate_heatmap`` computes.
:param heatmap: plate-aggregation callable used to count readable rows.
:param present: per-well row-count frame produced before numeric filtering.
:param subset: rows belonging to the plate being processed.
:param readable: boolean row mask, aligned with ``subset``, identifying
values that can be interpreted as numbers.
:param name: plate identifier passed to ``heatmap``.
:param variable: measurement column passed to ``heatmap``.
:returns: A presence frame aligned with ``present``, with zero only where
no readable numeric row exists.
"""
measured = subset[readable].copy()
if not len(measured):
return present * 0
numbers = heatmap(measured, name, variable, "count", "all", 0)[0]
numbers = numbers.reindex(index=present.index, columns=present.columns,
fill_value=0)
return present.where(numbers.to_numpy() > 0, 0)
[docs]
def shared_limits(matrices: Sequence[np.ndarray], min_max="allq"
) -> Tuple[float, float]:
"""One colour scale for every plate, over the wells that exist.
SHARED IS THE POINT. A plate heatmap is read by comparing plates; four
independent scales make the same colour mean four different numbers and
turn a batch effect into an invisible one.
:param matrices: per-plate value matrices; non-finite wells are excluded
from the shared scale.
:param min_max: A two-float sequence selects those quantiles; a two-item
sequence containing a non-float supplies absolute endpoints.
``'allq'`` selects the 2nd and 98th percentiles, while every other
value selects the finite extrema.
:returns: ``(low, high)`` shared by every matrix. An empty finite pool
returns ``(0.0, 1.0)``; equal endpoints are widened by ``1e-6``.
"""
pool = np.concatenate([m.ravel() for m in matrices]) if matrices \
else np.array([], dtype="float64")
pool = pool[np.isfinite(pool)]
if not pool.size:
return 0.0, 1.0
if isinstance(min_max, (list, tuple)) and len(min_max) == 2:
if all(isinstance(value, float) for value in min_max):
low, high = np.quantile(pool, [min_max[0], min_max[1]])
else:
low, high = float(min_max[0]), float(min_max[1])
elif min_max == "allq":
low, high = np.quantile(pool, [0.02, 0.98])
else:
low, high = float(np.nanmin(pool)), float(np.nanmax(pool))
low, high = float(low), float(high)
if low == high:
high = low + 1e-6
return low, high
[docs]
def small_multiple_layout(count: int, plate_aspect: float,
target: float = TARGET_ASPECT) -> Tuple[int, int]:
"""Rows and columns of plates that put the composite nearest square.
Four plates in a row is 4 x 1.5 = a 6:1 composite; four in a 2 x 2 is
1.5:1. Both hold the same picture; only one of them fills a tile.
:param count: number of plates to place. A non-positive count has no grid
and returns ``(0, 0)``.
:param plate_aspect: one plate's width over its height, in wells. For a
positive ``count``, this and ``target`` must be positive; unsupported
nonpositive ratios may propagate arithmetic domain or division errors.
:param target: the positive composite width-over-height to aim at.
:returns: ``(rows, columns)``.
"""
if count <= 0:
return 0, 0
best, choice = None, (1, count)
for columns in range(1, count + 1):
rows = -(-count // columns)
aspect = (columns * plate_aspect) / rows
penalty = abs(math.log(aspect / target))
if best is None or penalty < best - 1e-12:
best, choice = penalty, (rows, columns)
return choice
def _tick_step(n: int) -> int:
"""Label every well, every other one, or every fourth.
Twenty-four column numbers under a plate that is two inches wide at
6.2 pt is a solid line of digits. The step is chosen from the count
rather than the width because the width is derived from the count.
:param n: number of row or column labels available.
:returns: ``1`` through 12 labels, ``2`` through 26, otherwise ``4``.
"""
if n <= 12:
return 1
if n <= 26:
return 2
return 4
[docs]
def draw_plate(ax, matrix: np.ndarray, *, vmin: float, vmax: float, cmap,
ink: str, name: str = "", row_labels=None,
column_labels=None) -> None:
"""One plate into one axes, with square wells and no gridlines.
:param ax: matplotlib axes that receives the wash, image, ticks and title.
:param matrix: ``(n_rows, n_columns)``, ``nan`` where no well was
measured.
:param vmin: lower endpoint shared by the plate's colour normalisation.
:param vmax: upper endpoint shared by the plate's colour normalisation.
:param cmap: matplotlib colormap (or registered colormap name) used for
measured wells.
:param ink: colour used for ticks, labels, spines and the plate wash.
:param name: optional plate title; false values leave the title unset.
:param row_labels: ``True`` to draw the row letters, ``False`` to leave
the axis bare (an inner plate of the small multiple shares the
outer one's).
:param column_labels: truthy to draw column numbers; false values leave
the axis bare when an outer plate already supplies them.
:returns: ``None``; artists are added to ``ax`` in place.
"""
from matplotlib.patches import Rectangle
n_rows, n_columns = matrix.shape
ax.add_patch(Rectangle((0, 0), n_columns, n_rows,
facecolor=_wash(ink), edgecolor="none", zorder=0))
ax.imshow(np.ma.masked_invalid(matrix), cmap=cmap, vmin=vmin, vmax=vmax,
origin="upper", extent=(0, n_columns, n_rows, 0),
interpolation="nearest", aspect="equal", zorder=1)
from .. import schema as _schema
row_step, column_step = _tick_step(n_rows), _tick_step(n_columns)
ax.set_yticks([i + 0.5 for i in range(0, n_rows, row_step)])
ax.set_xticks([i + 0.5 for i in range(0, n_columns, column_step)])
ax.set_yticklabels(
[_schema.letters_from_row_index(i + 1)
for i in range(0, n_rows, row_step)] if row_labels else [])
ax.set_xticklabels(
[str(i + 1) for i in range(0, n_columns, column_step)]
if column_labels else [])
ax.tick_params(length=1.6, width=WEIGHTS["spine"], pad=1.4, colors=ink,
labelsize=TYPE_SCALE["tick"])
for spine in ax.spines.values():
spine.set_linewidth(WEIGHTS["spine"])
spine.set_color(ink)
if name:
ax.set_title(name, fontsize=TYPE_SCALE["annotation"], pad=2.0,
color=ink)
def _wash(ink: str) -> tuple:
"""Return the translucent wash for a well that was never measured.
:param ink: colour to convert to RGBA.
:returns: RGBA tuple whose alpha is :data:`EMPTY_WASH_ALPHA`.
"""
from matplotlib.colors import to_rgba
return to_rgba(ink, EMPTY_WASH_ALPHA)
[docs]
def build_plates(frame, variable: str, *, grouping: str = "mean",
min_max="allq", min_count=0, cmap=None,
target: Optional[str] = None, width: float = WIDTH,
plates: Optional[Sequence[str]] = None,
limits: Optional[Tuple[float, float]] = None,
outline: Optional[str] = None):
"""Every plate of a screen as one figure, on one colour scale.
:param frame: long-format frame with a ``prc`` column and ``variable``.
:param variable: the measurement to aggregate per well.
:param grouping: ``'mean'``, ``'sum'`` or ``'count'``.
:param min_count: minimum number of rows required for a well to be drawn.
:param min_max: colour-scale spec, as
:func:`spacr.plot.generate_plate_heatmap` defines it -- but applied
ONCE, over every plate at the same time.
:param cmap: a colormap to override the house ramp. ``None`` uses
:func:`plate_ramp`, which is what the style asks for.
:param target: ``'screen'`` or ``'print'``; defaults to the user's own
figure preference.
:param width: figure width in inches. The HEIGHT is derived from it, so
that the wells come out square.
:param plates: draw only these plates, in this order.
:param limits: an explicit ``(vmin, vmax)``, overriding ``min_max``.
THIS IS WHAT MAKES ONE-PLATE-PER-FIGURE SAFE: a caller that wants a
plate to a tile computes :func:`shared_limits` over every plate once
and passes the same pair to each figure, so splitting the small
multiple up does not silently give each plate its own scale again.
:param outline: a column of ``frame``; every well whose mean of it is
above zero gets a square outline in the ink colour. A hit call drawn
on the score it was called from, without a second figure.
:returns: ``(figure, Panel)``. With no matrix the panel has ``drawn=False``
plus its missing requirements and reason. Otherwise its caption
records the actual aggregation, well counts, and shared limits.
"""
import matplotlib.pyplot as plt
target = target or theme_target()
ink = resolve_ink(target)
with figure_style(target, frame="box"):
names, matrices, (n_rows, n_columns) = well_matrices(
frame, variable, grouping=grouping, min_count=min_count,
plates=plates)
if not matrices:
figure = plt.figure(figsize=(width, width / TARGET_ASPECT))
from .bundle import _register_figure_data
_register_figure_data(figure, frame, y=str(variable), kind="heatmap")
return figure, Panel(
"plates", "plate heatmaps", drawn=False,
reason=(f"no plate in this table carries a well grid for "
f"{variable!r}"),
needs=("prc", variable))
measured = [int(np.isfinite(m).sum()) for m in matrices]
vmin, vmax = (float(limits[0]), float(limits[1])) if limits \
else shared_limits(matrices, min_max)
marks = [None] * len(matrices)
if outline and outline in frame.columns:
_marked, outlined, _shape = well_matrices(
frame, outline, grouping="mean", min_count=min_count,
plates=names)
marks = [m if m.shape == matrix.shape else None
for m, matrix in zip(outlined, matrices)]
rows, columns = small_multiple_layout(
len(matrices), n_columns / max(n_rows, 1))
cell_w = (width - MARGIN["left"] - MARGIN["right"]
- (columns - 1) * MARGIN["wspace"]) / columns
cell_h = cell_w * n_rows / n_columns
height = (MARGIN["top"] + MARGIN["bottom"] + rows * cell_h
+ (rows - 1) * MARGIN["hspace"])
figure = plt.figure(figsize=(width, height))
from .bundle import _register_figure_data
ramp = plate_ramp(target) if cmap is None else _named(cmap)
image = None
for index, (name, matrix) in enumerate(zip(names, matrices)):
row, column = divmod(index, columns)
left = (MARGIN["left"] + column * (cell_w + MARGIN["wspace"])) / width
bottom = (MARGIN["bottom"] + (rows - row - 1)
* (cell_h + MARGIN["hspace"])) / height
ax = figure.add_axes([left, bottom, cell_w / width,
cell_h / height])
draw_plate(ax, matrix, vmin=vmin, vmax=vmax, cmap=ramp, ink=ink,
name=str(name), row_labels=column == 0,
column_labels=row == rows - 1
or index + columns >= len(matrices))
if marks[index] is not None:
_outline_wells(ax, marks[index], ink)
if image is None:
image = ax.images[0]
_colour_bar(figure, image, variable, ink, width, height)
from .. import schema as _schema
recipes = []
for index, (name, ax) in enumerate(zip(names, figure.axes[:-1])):
coordinates = {}
for key in frame["prc"].astype(str).unique():
parts = key.rsplit("_", 2)
if len(parts) == 3 and parts[0] == str(name):
row, column = _schema.row_index(parts[1]), _schema.column_index(parts[2])
if row is not None and column is not None:
coordinates[key] = [row, column]
recipes.append(dict(slot=index, rect=list(ax.get_position().bounds),
y=str(variable), measurement=f"{variable} ({name})", plate=dict(
name=str(name), variable=str(variable), grouping=str(grouping),
min_count=min_count if isinstance(min_count, (int, float)) else 0,
coordinates=coordinates, shape=[n_rows, n_columns],
limits=[vmin, vmax], colors=ramp(np.linspace(0, 1, ramp.N)).tolist(),
bad_color=ramp.get_bad().tolist(), under_color=ramp.get_under().tolist(),
over_color=ramp.get_over().tolist(), ink=ink, wash_alpha=EMPTY_WASH_ALPHA,
linewidth=WEIGHTS["spine"], tick_fontsize=TYPE_SCALE["tick"],
title_fontsize=TYPE_SCALE["annotation"],
xticks=ax.get_xticks().tolist(), yticks=ax.get_yticks().tolist(),
xticklabels=[tick.get_text() for tick in ax.get_xticklabels()],
yticklabels=[tick.get_text() for tick in ax.get_yticklabels()],
outline=str(outline) if outline and outline in frame.columns else "",
outline_linewidth=WEIGHTS["data"] * 0.6)))
_register_figure_data(figure, frame, y=str(variable), kind="plate_heatmap",
grid=[rows, columns], panels=recipes, plates=[str(name) for name in names],
plate_colorbar=dict(rect=list(figure.axes[-1].get_position().bounds),
ticks=[vmin, vmax], ink=ink, linewidth=WEIGHTS["spine"],
fontsize=TYPE_SCALE["annotation"], text_position=[0.5, (BAR["bottom"] - 0.022) / height],
text=str(variable).replace("_", " ").lower()))
blank = sum(m.size for m in matrices) - sum(measured)
subject = ("objects per well, counted" if grouping == "count" else
f"{variable} per well, "
+ ("summed" if grouping == "sum" else "averaged")
+ " over the objects in it")
plural = "" if len(matrices) == 1 else "s"
return figure, Panel(
"plates", "plate heatmaps",
caption=(
f"{subject}, for {len(matrices)} plate{plural} of "
f"{n_rows}x{n_columns} wells. All plates share one colour "
f"scale ({vmin:.3g} to {vmax:.3g}) so the same colour is the "
f"same number on every plate. {sum(measured)} wells were "
f"measured; the {blank} that were not are left as a neutral "
f"wash and are excluded from the scale."
+ (f" {sum(int(np.nansum(m > 0)) for m in marks if m is not None)}"
f" well(s) marked by {outline} are outlined."
if outline else "")),
needs=("prc", variable))
def _outline_wells(ax, marks: np.ndarray, ink: str) -> None:
"""Draw a square outline round every well whose mark is above zero.
:param ax: the plate's axes, in well units as :func:`draw_plate` sets it.
:param marks: per-well matrix; NaN and non-positive wells are unmarked.
:param ink: outline colour.
:returns: ``None``; patches are added to ``ax``.
"""
from matplotlib.patches import Rectangle
inset = 0.1
for r, c in zip(*np.nonzero(np.nan_to_num(marks) > 0)):
ax.add_patch(Rectangle((c + inset, r + inset), 1 - 2 * inset,
1 - 2 * inset, fill=False, edgecolor=ink,
linewidth=WEIGHTS["data"] * 0.6, zorder=3))
def _named(cmap):
"""Return a detached colormap with transparent missing values.
:param cmap: registered colormap name or colormap object.
:returns: A copy whose bad-value colour is transparent. The caller's
colormap object is not mutated.
"""
if isinstance(cmap, str):
from matplotlib import colormaps
cmap = colormaps[cmap]
cmap = cmap.copy()
cmap.set_bad("none")
return cmap
def _colour_bar(figure, image, variable: str, ink: str, width: float,
height: float) -> None:
"""One thin horizontal bar under the whole small multiple.
One, because there is one scale. Horizontal and low, because the
composite is wider than it is tall and a bar down the right side would
steal a plate's width.
:param figure: figure receiving the colour-bar axes and variable label.
:param image: plotted image carrying the shared colour normalization.
:param variable: measurement name rendered below the bar.
:param ink: colour used for the outline, ticks, and label.
:param width: figure width in inches.
:param height: figure height in inches.
:returns: ``None``; one axes and one text artist are added to ``figure``.
"""
bar_w = min(BAR["width"], width * 0.42)
cax = figure.add_axes([(width - bar_w) / 2 / width, BAR["bottom"] / height,
bar_w / width, BAR["height"] / height])
cax.set_label("<colorbar>")
bar = figure.colorbar(image, cax=cax, orientation="horizontal")
bar.outline.set_linewidth(WEIGHTS["spine"])
bar.outline.set_edgecolor(ink)
cax.tick_params(length=1.6, width=WEIGHTS["spine"], pad=1.2,
labelsize=TYPE_SCALE["annotation"], colors=ink)
bar.set_ticks([image.norm.vmin, image.norm.vmax])
cax.set_xticklabels([f"{image.norm.vmin:.3g}", f"{image.norm.vmax:.3g}"])
figure.text(0.5, (BAR["bottom"] - 0.022) / height,
str(variable).replace("_", " ").lower(),
ha="center", va="top", color=ink,
fontsize=TYPE_SCALE["annotation"])
__all__ = ["BAR", "EMPTY_WASH_ALPHA", "MARGIN", "TARGET_ASPECT", "WIDTH",
"build_plates", "draw_plate", "full_plate_grid",
"plate_figure_name", "plate_names", "plate_ramp", "score_ramp",
"shared_limits",
"small_multiple_layout", "well_matrices"]