diff --git a/AGENTS.md b/AGENTS.md
index 8f35c9e2d..f58d7a773 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,7 +115,7 @@ grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js
```
and reconcile against the two numbered tables (the section map near the top and
-the 2-D function table). Both were last verified at 12,211 lines.
+the 2-D function table). Both were last verified at 12,349 lines.
Changelog entries: add a fragment file to `upcoming_changes/` (e.g.
`123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time.
diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md
index da85c815b..1c8802e50 100644
--- a/anyplotlib/FIGURE_ESM.md
+++ b/anyplotlib/FIGURE_ESM.md
@@ -35,8 +35,8 @@ Rule 4 – Zoom is relative to the fit-rect.
zoom=Z → a 1/Z portion of the image fills the fit-rect.
Rule 5 – Text never clips. Optional gutters earn real layout space:
- the colorbar (strip + label, _cbWidth) is subtracted from the
- image width; the 2D title strip (_padT) grows for large or TeX
+ the colorbar (strip + value gutter + label, _cbWidth) is
+ subtracted from the image width; the 2D title strip (_padT) grows for large or TeX
titles; 1D/bar titles clamp their drawn size to the fixed strip
(_titlePx); edge tick labels are nudged inward.
```
@@ -51,30 +51,30 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| Theme (dark/light detection) | 26 |
| Shared math helpers | 64 |
| b64 array decode helpers | 109 |
-| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 161 / 228 / 250 |
-| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 313 / 323 / 333 |
-| **Layout engine** `applyLayout` | 824 |
-| `_buildCanvasStack` | 907 |
-| `_createPanelDOM` | 1049 |
-| `_createInsetDOM` / `_applyAllInsetStates` | 1190 / 1584 |
-| `_resizePanelDOM` | 2297 |
-| **2D drawing**: `_imgFitRect` | 2461 |
-| `draw2d` | 2790 |
-| `drawScaleBar2d` / `drawColorbar2d` | 2985 / 3265 |
-| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3084 / 3107 / 3120 |
-| `_drawAxes2d` (ticks, labels, title) | 3319 |
-| `drawOverlay2d` / `drawMarkers2d` | 3472 / 3636 |
-| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2610 / 2634 / 2695 |
-| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 780 / 811 |
-| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4480 / 4562 |
-| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4602 / 4617 / 4638 |
-| **3D drawing**: `draw3d` | 5614 |
-| Event emission `_emitEvent` | 6451 |
-| 3D event handlers `_attachEvents3d` | 6508 |
-| **1D drawing**: `draw1d` | 6732 |
-| `_drawLine` (1D series + markers) | 6885 |
-| `drawOverlay1d` / `drawMarkers1d` | 7178 / 7262 |
-| Marker hit-test `_markerHitTest2d` | 7530 |
+| **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 175 / 242 / 264 |
+| **2D gutter geometry**: `_cbLabelW` / `_cbValueChars` / `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 313 / 324 / 342 / 354 / 364 / 374 / 384 |
+| **Layout engine** `applyLayout` | 875 |
+| `_buildCanvasStack` | 958 |
+| `_createPanelDOM` | 1100 |
+| `_createInsetDOM` / `_applyAllInsetStates` | 1241 / 1635 |
+| `_resizePanelDOM` | 2348 |
+| **2D drawing**: `_imgFitRect` | 2519 |
+| `draw2d` | 2863 |
+| `drawScaleBar2d` / `drawColorbar2d` | 3058 / 3338 |
+| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3157 / 3180 / 3193 |
+| `_drawAxes2d` (ticks, labels, title) | 3450 |
+| `drawOverlay2d` / `drawMarkers2d` | 3603 / 3767 |
+| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2683 / 2707 / 2768 |
+| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 831 / 862 |
+| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4611 / 4693 |
+| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4733 / 4748 / 4769 |
+| **3D drawing**: `draw3d` | 5745 |
+| Event emission `_emitEvent` | 6582 |
+| 3D event handlers `_attachEvents3d` | 6639 |
+| **1D drawing**: `draw1d` | 6863 |
+| `_drawLine` (1D series + markers) | 7016 |
+| `drawOverlay1d` / `drawMarkers1d` | 7309 / 7393 |
+| Marker hit-test `_markerHitTest2d` | 7661 |
> **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'`
> branch that blits a single RGBA image across data-coord `extent` (the fast
@@ -83,22 +83,22 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
> redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on
> the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block
> clips it to a curved sector.
-| Panel event dispatch `_attachPanelEvents` | 7787 |
-| 2D events `_attachEvents2d` | 7829 |
-| 1D events `_attachEvents1d` | 8222 |
-| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8497 / 8776 |
-| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8689 / 8703 / 8732 / 8767 |
-| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8901 / 8974 |
-| Shared-axis propagation `_getShareGroups` | 9045 |
-| Figure resize `_applyFigResizeDOM` | 9109 |
-| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9300 / 9363 / 9739 |
-| Generic redraw `_redrawPanel` | 9929 |
-| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10088 / 10284 / 10343 |
-| Native-resolution render `_withNativeSize` | 10064 |
-| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10377 / 10486 / 10665 |
-| Export registry `registerExportAction` | 10542 |
-| **Embedding API**: `createLocalModel` / `mount` | 11056 / 11112 |
-| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11367 / 11754 |
+| Panel event dispatch `_attachPanelEvents` | 7918 |
+| 2D events `_attachEvents2d` | 7960 |
+| 1D events `_attachEvents1d` | 8353 |
+| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8628 / 8907 |
+| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8820 / 8834 / 8863 / 8898 |
+| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 9032 / 9105 |
+| Shared-axis propagation `_getShareGroups` | 9176 |
+| Figure resize `_applyFigResizeDOM` | 9240 |
+| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9434 / 9497 / 9873 |
+| Generic redraw `_redrawPanel` | 10063 |
+| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10226 / 10422 / 10481 |
+| Native-resolution render `_withNativeSize` | 10202 |
+| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10515 / 10624 / 10803 |
+| Export registry `registerExportAction` | 10680 |
+| **Embedding API**: `createLocalModel` / `mount` | 11194 / 11250 |
+| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11505 / 11892 |
> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one
> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods`
@@ -148,7 +148,10 @@ with fallbacks to the historical defaults: `title_size||11`,
| Function | Purpose |
|----------|---------|
-| `_cbWidth(st)` | Width reserved for the colorbar: 0 when hidden, else `16 + (label ? label_size+8 : 0)`. Subtracted from the image width in `_resizePanelDOM` / `_resizePanelCSS` so the strip + label always fit inside the panel. |
+| `_cbLabelW(st)` | The rotated-label gutter: `label_size + 8`, 0 without a label. |
+| `_cbValueChars(st)` | Characters budgeted for the `display_min` / `display_max` values: 7 (all of `fmtRange`'s ordinary output, so a contrast drag never moves the image), more only for longer strings such as a three-digit exponent. `fmtRange` (next to `fmtVal`) formats BOTH ends alike — exponent notation when the larger magnitude is outside [1e-2, 1e4), else decimals from the span (three significant digits of it, so ±1.25 reads 1.25). Mirrored by Python `_colorbar_value_chars` / `colorbar_texts`. |
+| `_cbTickW(st, availW)` | Width of the value gutter right of the strip: `round(chars * 0.6 * tick_size) + 3`. With `availW` (image + colorbar width after the axis gutters) it is 0 when the values would leave less than `CB_MIN_IMAGE_W` (40 px) of image — a narrow grid cell keeps the strip and label and drops the numbers. `_resizePanelDOM` stores the result on `p._cbTickW` for `drawColorbar2d`. Mirrored in Python's `plot_box`. |
+| `_cbWidth(st, availW)` | Width reserved for the colorbar: 0 when hidden, else `16 + _cbTickW + _cbLabelW`. Subtracted from the image width in `_resizePanelDOM` / `_resizePanelCSS` so the strip, its values and its label always fit inside the panel. |
| `_padT(st)` | 2D title-strip height: `PAD_T` (12) for default-size plain titles (pixel-identical layouts); grows to `ceil(size*1.3)+2..4` for `title_size > 11` or TeX titles (superscript rise). Stored as `p._padT`. |
| `_titlePx(st)` | Drawn title size for fixed-strip panels (1D/bar): clamps to 11 (10 for TeX titles) so nothing clips. |
@@ -279,13 +282,13 @@ st.colorbar_label_size (label font sizes; optional)
| Function | Line | Purpose |
|----------|------|---------|
-| **`_imgFitRect(iw,ih,cw,ch)`** | **2384** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this |
-| `draw2d(p)` | 2713 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
-| `drawScaleBar2d(p)` | 2908 | Physical scale bar |
-| `_rawBand(st)` / `_buildLut32(st)` | 2433 / 2440 | The quantisation band the u8 bytes were encoded over, then the 256-entry LUT built from it. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black |
-| `drawColorbar2d(p)` | 3188 | Gradient strip + min/max marks (band-relative, via `_rawBand`) + rotated label centred in the `_cbWidth` gutter |
-| `_drawAxes2d(p)` | 3242 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
-| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3395 / 3559 | Widgets / marker groups |
+| **`_imgFitRect(iw,ih,cw,ch)`** / `_cbFitRect(st,imgW,imgH)` | **2519 / 2528** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this. `_cbFitRect` is its vertical extent in whole px — the colorbar strip spans the letterboxed IMAGE, not the whole image area (`p._cbH`) |
+| `draw2d(p)` | 2863 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers |
+| `drawScaleBar2d(p)` | 3058 | Physical scale bar |
+| `_rawBand(st)` / `_displayFrac(st, val)` / `_buildLut32(st)` | 2578 / 2588 / 2596 | The quantisation band the u8 bytes were encoded over, the ONE rule mapping a value through the display window and scale mode to a colormap fraction (shared by the LUT and the colorbar strip, so the strip's colours are the image's), then the 256-entry LUT built from both. `_rawBand` mirrors Python `_tile_quant_clim`: a DEGENERATE band (`raw_max <= raw_min`) is UNSET and falls back to `display_min/max`. Both render paths and the colorbar go through it — honouring a `(0, 0)` band paints solid black |
+| `drawColorbar2d(p)` | 3338 | Gradient strip coloured through the display window (`_displayFrac` — saturated beyond it, like the image) + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtRange`; kept inside the strip by the glyphs' measured extent, held apart on a tiny range, the maximum alone at its own place on a strip too short for both; omitted when the layout dropped the gutter, `p._cbTickW == 0`) + rotated label centred right after the values' measured width (never past the reserved gutter) |
+| `_drawAxes2d(p)` | 3450 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` |
+| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3603 / 3767 | Widgets / marker groups |
Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a
`1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact
@@ -638,13 +641,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws
| Function | Line | Purpose |
|----------|------|---------|
-| `_cssScale` | 9964 | inverse of `_applyScale`'s `transform:scale()` |
-| `_panelBox` | 9975 | the element whose rect bounds one panel |
-| `_neutralizeView` / `_restoreView` | 9984 / 10009 | transient whole-extent view |
-| `_nativeGeom` / `_nativeGuard` | 10024 / 10039 | native size + why-not message |
-| `_withNativeSize` | 10064 | resize → redraw → run → restore |
-| `_compositeCanvas` | 10088 | the compositor (`_drawEl` / `_drawPanel` …) |
-| `exportCanvas` / `exportPNG` | 10284 / 10343 | orchestrator / data-URL wrapper |
+| `_cssScale` | 10098 | inverse of `_applyScale`'s `transform:scale()` |
+| `_panelBox` | 10109 | the element whose rect bounds one panel |
+| `_neutralizeView` / `_restoreView` | 10118 / 10143 | transient whole-extent view |
+| `_nativeGeom` / `_nativeGuard` | 10158 / 10177 | native size + why-not message |
+| `_withNativeSize` | 10202 | resize → redraw → run → restore |
+| `_compositeCanvas` | 10226 | the compositor (`_drawEl` / `_drawPanel` …) |
+| `exportCanvas` / `exportPNG` | 10422 / 10481 | orchestrator / data-URL wrapper |
**The whole pipeline is ONE synchronous task** — theme swap, view reset, native
resize, composite, restore — so the browser never paints an intermediate state
@@ -740,13 +743,13 @@ leaders that cross into the panel included. Pinned by
| Function | Line | Purpose |
|----------|------|---------|
-| `_toast` | 10377 | transient bottom-centre message |
-| `_copyCanvas` | 10412 | clipboard write + feature detection |
-| `_showPngPreview` | 10436 | framed-document download fallback |
-| `_downloadCanvas` | 10486 | `` or the preview |
-| `registerExportAction` | 10542 | downstream extension point |
-| `_menuRows` / `_openMenu` | 10596 / 10665 | menu model / DOM |
-| `_panelAtPoint` | 10777 | hit test (insets first — they sit on top) |
+| `_toast` | 10515 | transient bottom-centre message |
+| `_copyCanvas` | 10550 | clipboard write + feature detection |
+| `_showPngPreview` | 10574 | framed-document download fallback |
+| `_downloadCanvas` | 10624 | `` or the preview |
+| `registerExportAction` | 10680 | downstream extension point |
+| `_menuRows` / `_openMenu` | 10734 / 10803 | menu model / DOM |
+| `_panelAtPoint` | 10915 | hit test (insets first — they sit on top) |
- **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an
ordinary left click.** It is a `role="button"` with `tabIndex=0` and
@@ -830,16 +833,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind.
| Function | Line | Purpose |
|----------|------|---------|
-| `decodeBlocks` | 11367 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
-| `dense` | 11393 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
-| `ragged` | 11458 | the same three, over a row-pointer block (`offsets` + one array per column) |
-| `maskFromWidget` | 11541 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
-| `rasterDisks` | 11581 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
-| `robustLevels` / `toU8` | 11610 / 11651 | the percentile window and the 8-bit code map, one implementation |
-| `panelAxis` | 11729 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
-| `installTouchShim` / `reportEmbedHeight` | 11666 / 11685 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
-| `encodeBase64` / `typedArrayBytes` | 11705 / 11713 | a 3-D cloud's geometry channel is base64, not the binary side table |
-| `mountNavigated` | 11754 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
+| `decodeBlocks` | 11505 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry |
+| `dense` | 11531 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes |
+| `ragged` | 11596 | the same three, over a row-pointer block (`offsets` + one array per column) |
+| `maskFromWidget` | 11679 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) |
+| `rasterDisks` | 11719 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel |
+| `robustLevels` / `toU8` | 11748 / 11789 | the percentile window and the 8-bit code map, one implementation |
+| `panelAxis` | 11867 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) |
+| `installTouchShim` / `reportEmbedHeight` | 11804 / 11823 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` |
+| `encodeBase64` / `typedArrayBytes` | 11843 / 11851 | a 3-D cloud's geometry channel is base64, not the binary side table |
+| `mountNavigated` | 11892 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` |
`mountNavigated(el, page, opts)` is **async** — the blob decode is a `fetch` of
a `data:` URL — so a host `await`s it. `page` is `{state, blocks, bindings,
diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py
index 1dfe69cec..43e200c09 100644
--- a/anyplotlib/_base_plot.py
+++ b/anyplotlib/_base_plot.py
@@ -6,11 +6,99 @@
from __future__ import annotations
+import decimal
+import math
+
from contextlib import contextmanager
from anyplotlib.callbacks import _EventMixin
+#: Below this many px of image the colorbar's value gutter is dropped
+#: (mirrors ``CB_MIN_IMAGE_W`` in the JS).
+COLORBAR_MIN_IMAGE_WIDTH = 40
+
+
+def _js_round(x: float) -> int:
+ """JavaScript's ``Math.round`` — half away from zero for positive values —
+ where Python's ``round`` would tie to even and put the two sides of the
+ layout mirror a pixel apart."""
+ return int(math.floor(float(x) + 0.5))
+
+
+def _js_exponential(value: float, digits: int) -> str:
+ """JavaScript's ``Number.toExponential(digits)``: a signed mantissa with
+ *digits* decimals, rounded half away from zero on the exact binary value
+ (``12500`` → ``1.3e+4``, where ``f"{:e}"`` would tie to even), and an
+ exponent without leading zeros (``1.2e+4``, ``-1.5e-10``)."""
+ number = decimal.Decimal(value)
+ if number == 0:
+ return f"{0:.{digits}f}e+0"
+ sign = "-" if number < 0 else ""
+ number = abs(number)
+ exponent = number.adjusted()
+ quantum = decimal.Decimal(1).scaleb(-digits)
+ mantissa = number.scaleb(-exponent).quantize(quantum, rounding=decimal.ROUND_HALF_UP)
+ if mantissa >= 10:
+ mantissa = (mantissa / 10).quantize(quantum, rounding=decimal.ROUND_HALF_UP)
+ exponent += 1
+ return f"{sign}{mantissa}e{exponent:+d}"
+
+
+def _js_fixed(value: float, digits: int) -> str:
+ """JavaScript's ``Number.toFixed(digits)``, which rounds half away from
+ zero on the exact binary value."""
+ quantum = decimal.Decimal(1).scaleb(-digits)
+ rounded = decimal.Decimal(value).quantize(quantum, rounding=decimal.ROUND_HALF_UP)
+ return f"{rounded:f}"
+
+
+def _strip_zeros(text: str) -> str:
+ return text.rstrip("0").rstrip(".") if "." in text else text
+
+
+def colorbar_texts(low: float, high: float) -> tuple[str, str]:
+ """The two values the renderer writes beside the colorbar (``fmtRange``),
+ mirrored so Python can budget the same width.
+
+ One format for both ends, so ``0.02`` never sits beside ``-5.0e-3``:
+ exponent notation when the larger magnitude is outside ``[1e-2, 1e4)``,
+ else fixed with enough decimals to show the ends exactly — three
+ significant digits of the span, so ±1.25 reads ``1.25`` and not ``1.3``;
+ at most four decimals. Non-finite ends give ``""`` (the renderer draws
+ nothing)."""
+ low, high = float(low), float(high)
+ if not (math.isfinite(low) and math.isfinite(high)):
+ return "", ""
+ big = max(abs(low), abs(high))
+ if big == 0:
+ return "0", "0"
+ if big >= 1e4 or big < 1e-2:
+ return _js_exponential(low, 1), _js_exponential(high, 1)
+ span = abs(high - low) or big
+ decimals = min(4, max(0, math.ceil(-math.log10(span)) + 2))
+ return (_strip_zeros(_js_fixed(low, decimals)),
+ _strip_zeros(_js_fixed(high, decimals)))
+
+
+def _colorbar_value_chars(state: dict) -> int:
+ """Characters budgeted for the colorbar's min/max values: 7 in ordinary
+ use, so the layout is stable under a contrast drag, more only for the rare
+ longer strings. Mirrors ``_cbValueChars`` in the JS."""
+ chars = 7
+ lo, hi = state.get("display_min"), state.get("display_max")
+ if lo is not None and hi is not None:
+ chars = max(chars, *(len(t) for t in colorbar_texts(lo, hi)))
+ return chars
+
+
+def _colorbar_value_width(state: dict) -> int:
+ """The value gutter in px — mirrors ``_cbTickW`` in the JS before its
+ narrow-cell rule."""
+ tick = state.get("tick_size") or 10
+ return _js_round(_colorbar_value_chars(state) * 0.6 * tick) + 3
+
+
class _BasePlot(_EventMixin):
"""Universal base for Plot1D, Plot2D, PlotBar, and Plot3D.
@@ -376,14 +464,20 @@ def plot_box(self) -> dict:
width = max((pw - left - right) if has_axes else pw, 1.0)
height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0)
- # The colorbar strip and its gap come out of the image width.
+ # The colorbar strip, its value gutter, its label and its gap come out
+ # of the image width. Mirrors `_cbWidth` / `_cbTickW` in the JS,
+ # including the rule that drops the values in a cell too narrow for
+ # them (`width` here is the JS `availW`).
if self._state.get("show_colorbar") and not self._state.get("is_rgb"):
label = self._state.get("colorbar_label")
- label_w = round((self._state.get("colorbar_label_size") or 10) + 8) \
+ label_w = _js_round((self._state.get("colorbar_label_size") or 10) + 8) \
if label else 0
+ tick_w = _colorbar_value_width(self._state)
pad = self._state.get("colorbar_pad")
gap = 6.0 if pad is None else max(0.0, float(pad))
- width = max(width - (16 + label_w) - gap, 1.0)
+ if width - gap - 16 - label_w - tick_w < COLORBAR_MIN_IMAGE_WIDTH:
+ tick_w = 0
+ width = max(width - (16 + tick_w + label_w) - gap, 1.0)
if is_image:
# Images are drawn "contain" — see _imgFitRect.
diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js
index 30d6bc687..39f41220e 100644
--- a/anyplotlib/figure_esm.js
+++ b/anyplotlib/figure_esm.js
@@ -81,6 +81,21 @@ function render({ model, el, onResize, onReadout }) {
return v.toExponential(1);
}
function stripZeros(s){ return s.indexOf('.')<0 ? s : s.replace(/\.?0+$/,''); }
+ // One format for BOTH ends of a range, so "0.02" never sits beside
+ // "-5.0e-3": exponent notation when the larger magnitude is outside
+ // [1e-2, 1e4), else fixed with enough decimals to show the ends exactly
+ // (three significant digits of the span, so +/-1.25 reads 1.25 and not 1.3;
+ // at most four decimals). Non-finite ends format as '' and are not drawn.
+ // Mirrored by Python's colorbar_texts.
+ function fmtRange(lo, hi) {
+ if(!Number.isFinite(lo)||!Number.isFinite(hi)) return ['',''];
+ const big=Math.max(Math.abs(lo),Math.abs(hi));
+ if(big===0) return ['0','0'];
+ if(big>=1e4||big<1e-2) return [lo.toExponential(1), hi.toExponential(1)];
+ const span=Math.abs(hi-lo)||big;
+ const decimals=Math.min(4,Math.max(0,Math.ceil(-Math.log10(span))+2));
+ return [stripZeros(lo.toFixed(decimals)), stripZeros(hi.toFixed(decimals))];
+ }
function _axisValToFrac(arr,val) {
if(arr.length<2) return 0;
const n=arr.length, asc=arr[n-1]>=arr[0];
@@ -295,14 +310,51 @@ function render({ model, el, onResize, onReadout }) {
}
// ── 2D gutter geometry helpers ───────────────────────────────────────────
- // Total width reserved for the colorbar (strip + rotated-label gutter).
- // 0 when the colorbar is hidden. The image area shrinks by this amount so
- // the strip and its label always fit inside the panel.
- function _cbWidth(st) {
- if (!st || !st.show_colorbar || st.is_rgb) return 0;
- const labelW = st.colorbar_label
+ // The colorbar's rotated-label gutter: the label's font size plus a margin.
+ function _cbLabelW(st) {
+ return st && st.colorbar_label
? Math.round((st.colorbar_label_size || 10) + 8) : 0;
- return 16 + labelW;
+ }
+
+ // Characters budgeted for the display_min / display_max values beside the
+ // strip. fmtRange is at most 7 characters in ordinary use ("-0.1234",
+ // "-1.2e+5"); the budget is fixed at that so the image does not wobble as the
+ // contrast handles are dragged, and grows only for the rare longer strings
+ // (a three-digit exponent, "-1.2e+308"), which would otherwise be clipped.
+ // Mirrored by Python's _colorbar_value_chars.
+ function _cbValueChars(st) {
+ let chars = 7;
+ if (st && st.display_min != null && st.display_max != null) {
+ const [lo, hi] = fmtRange(st.display_min, st.display_max);
+ chars = Math.max(chars, lo.length, hi.length);
+ }
+ return chars;
+ }
+
+ // Below this many px of image, the value gutter is dropped (strip and label
+ // stay) so a narrow grid cell does not push the colorbar over its neighbour.
+ const CB_MIN_IMAGE_W = 40;
+
+ // Width of the gutter the display_min / display_max values are written in,
+ // right of the strip: the character budget at ~0.6 em of the tick size,
+ // plus a 3 px gap off the strip. With `availW` (the width the image and
+ // colorbar share, after the axis gutters) it is 0 when the values would not
+ // leave CB_MIN_IMAGE_W for the image. Mirrored in Python's plot_box.
+ function _cbTickW(st, availW) {
+ const tick = (st && st.tick_size) || 10;
+ const w = Math.round(_cbValueChars(st) * 0.6 * tick) + 3;
+ if (availW != null && availW - _cbGap(st) - 16 - _cbLabelW(st) - w < CB_MIN_IMAGE_W)
+ return 0;
+ return w;
+ }
+
+ // Total width reserved for the colorbar (strip + value gutter + rotated-
+ // label gutter). 0 when the colorbar is hidden. The image area shrinks by
+ // this amount so the strip, its numbers and its label always fit inside the
+ // panel.
+ function _cbWidth(st, availW) {
+ if (!st || !st.show_colorbar || st.is_rgb) return 0;
+ return 16 + _cbTickW(st, availW) + _cbLabelW(st);
}
// Gap between the right edge of the image and the colorbar strip. Without a
@@ -2322,14 +2374,15 @@ function render({ model, el, onResize, onReadout }) {
&& st.y_axis && st.y_axis.length >= 2;
// Always reserve the top strip for the title (mirrors 1D behaviour).
// Left/right/bottom gutters are only used when physical axes are present.
- // The colorbar (strip + label gutter) takes space from the image width
- // so it is never clipped at the panel's right edge.
- const cbW = _cbWidth(st);
+ // The colorbar (strip + values + label gutter) takes space from the
+ // image width so it is never clipped at the panel's right edge; in a
+ // cell too narrow for all three the values go first (_cbTickW).
+ const availW = hasPhysAxis ? pw - PAD_L - PAD_R : pw;
+ const cbW = _cbWidth(st, availW);
const padT = _padT(st);
const imgX = hasPhysAxis ? PAD_L : 0;
const imgY = padT;
- const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw)
- - (cbW ? cbW + _cbGap(st) : 0));
+ const imgW = Math.max(1, availW - (cbW ? cbW + _cbGap(st) : 0));
let imgH = Math.max(1, ph - padT - (hasPhysAxis ? PAD_B : 0));
// Enforce aspect ratio (st.aspect = number or "equal" → 1.0).
if (st && st.aspect != null) {
@@ -2340,6 +2393,7 @@ function render({ model, el, onResize, onReadout }) {
// _cbW/_padT let draw2d detect when a state push requires a re-layout.
p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH;
p._cbW = cbW; p._padT = padT;
+ p._cbTickW = _cbTickW(st, availW); // what drawColorbar2d may write
// Title canvas: sits in the title strip above the image area
if (p.titleCanvas && p.titleCtx) {
@@ -2428,13 +2482,18 @@ function render({ model, el, onResize, onReadout }) {
}
}
- // Colorbar: strip + label gutter in the space reserved by _cbWidth
+ // Colorbar: strip + label gutter in the space reserved by _cbWidth.
+ // The strip spans the IMAGE — its letterboxed fit rect — not the whole
+ // image area, so a wide image's numbers sit beside its pixels rather
+ // than floating far above and below them.
if (p.cbCanvas && p.cbCtx) {
if (cbW) {
+ const fit = _cbFitRect(st, imgW, imgH);
+ p._cbH = fit.h;
p.cbCanvas.style.display = 'block';
p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px';
- p.cbCanvas.style.top = imgY + 'px';
- _sz(p.cbCanvas, p.cbCtx, cbW, imgH);
+ p.cbCanvas.style.top = (imgY + fit.y) + 'px';
+ _sz(p.cbCanvas, p.cbCtx, cbW, fit.h);
} else {
p.cbCanvas.style.display = 'none';
}
@@ -2464,6 +2523,16 @@ function render({ model, el, onResize, onReadout }) {
return { x: (cw - fw) / 2, y: (ch - fh) / 2, w: fw, h: fh, s };
}
+ // The vertical extent the colorbar strip spans: the image's letterboxed
+ // fit rect inside the image area, in whole px (the whole area when the
+ // image size is not known yet).
+ function _cbFitRect(st, imgW, imgH) {
+ const iw = st && st.image_width, ih = st && st.image_height;
+ if (!(iw > 0 && ih > 0)) return { y: 0, h: Math.max(1, imgH) };
+ const fit = _imgFitRect(iw, ih, imgW, imgH);
+ return { y: Math.round(fit.y), h: Math.max(1, Math.round(fit.h)) };
+ }
+
// On-screen rect occupied by image pixels at the current zoom.
// zoom>=1: full fit-rect; zoom<1: centred shrunken rect inside the fit-rect.
function _imgVisibleRect2d(st, pw, ph) {
@@ -2514,10 +2583,19 @@ function render({ model, el, onResize, onReadout }) {
return st.tile_enabled ? [st.display_min, st.display_max] : [lo,hi];
}
- function _buildLut32(st) {
+ // The fraction of the colormap a value maps to through the display window
+ // and scale mode — the ONE rule the image LUT and the colorbar strip share,
+ // so the strip's colours are the image's (saturated beyond the window).
+ function _displayFrac(st, val) {
const dMin=st.display_min, dMax=st.display_max;
- const [hMin,hMax]=_rawBand(st);
const mode=st.scale_mode||'linear';
+ if(mode==='log'){const dMC=Math.max(dMin,1e-10),dXC=Math.max(dMax,dMC+1e-10);return (Math.log10(Math.max(val,1e-10))-Math.log10(dMC))/(Math.log10(dXC)-Math.log10(dMC));}
+ if(mode==='symlog'){const lt=Math.max((dMax-dMin)*0.01,1e-10);const sl=v=>v>=0?(v<=lt?v/lt:1+Math.log10(v/lt)):-(Math.abs(v)<=lt?Math.abs(v)/lt:1+Math.log10(Math.abs(v)/lt));return (sl(val)-sl(dMin))/((sl(dMax)-sl(dMin))||1);}
+ return (val-dMin)/((dMax-dMin)||1);
+ }
+
+ function _buildLut32(st) {
+ const [hMin,hMax]=_rawBand(st);
const range=hMax-hMin||1;
const cmapData=st.colormap_data||[];
let cmapFlat=null;
@@ -2528,11 +2606,7 @@ function render({ model, el, onResize, onReadout }) {
const lut=new Uint32Array(256);
const buf=new ArrayBuffer(4); const dv=new DataView(buf); const u32=new Uint32Array(buf);
for(let raw=0;raw<256;raw++){
- const val=hMin+(raw/255)*range;
- let t;
- if(mode==='log'){const dMC=Math.max(dMin,1e-10),dXC=Math.max(dMax,dMC+1e-10);t=(Math.log10(Math.max(val,1e-10))-Math.log10(dMC))/(Math.log10(dXC)-Math.log10(dMC));}
- else if(mode==='symlog'){const lt=Math.max((dMax-dMin)*0.01,1e-10);const sl=v=>v>=0?(v<=lt?v/lt:1+Math.log10(v/lt)):-(Math.abs(v)<=lt?Math.abs(v)/lt:1+Math.log10(Math.abs(v)/lt));t=(sl(val)-sl(dMin))/((sl(dMax)-sl(dMin))||1);}
- else{t=(val-dMin)/((dMax-dMin)||1);}
+ const t=_displayFrac(st,hMin+(raw/255)*range);
const idx=Math.max(0,Math.min(255,Math.round(t*255)));
if(cmapFlat){dv.setUint8(0,cmapFlat[idx*4]);dv.setUint8(1,cmapFlat[idx*4+1]);dv.setUint8(2,cmapFlat[idx*4+2]);dv.setUint8(3,255);}
else{dv.setUint8(0,idx);dv.setUint8(1,idx);dv.setUint8(2,idx);dv.setUint8(3,255);}
@@ -3270,16 +3344,30 @@ function render({ model, el, onResize, onReadout }) {
const cbStripW=16;
const cbLabel=st.colorbar_label||'';
- const cbW=_cbWidth(st)||cbStripW;
- const imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B);
+ // The value gutter the layout reserved for this panel (0 in a cell too
+ // narrow for it); the strip and label always fit.
+ const tickW=(p._cbTickW!=null)?p._cbTickW:_cbTickW(st);
+ const labelW=_cbLabelW(st);
+ const cbW=cbStripW+tickW+labelW;
+ // The strip is as tall as the image's letterboxed rect (_resizePanelDOM),
+ // not the whole image area, so its numbers sit beside the pixels.
+ const imgH=p._cbH||p.imgH||Math.max(1,p.ph-PAD_T-PAD_B);
const ctx=p.cbCtx;
ctx.clearRect(0,0,cbW,imgH);
- // Gradient strip
+ // display_min / display_max, and the raw band the strip's rows span.
+ const dMin=st.display_min, dMax=st.display_max;
+ const [hMin,hMax]=_rawBand(st);
+ const vRange=(hMax-hMin)||1;
+
+ // Gradient strip: each row's raw value coloured the way the image colours
+ // it — through the display window, saturated beyond it — so the numbers
+ // written at the marks are the values of the colours beside them.
if(st.colormap_data&&st.colormap_data.length===256){
for(let py=0;py0&&loText&&hiText){
+ const tickPx=st.tick_size||10;
+ ctx.fillStyle=theme.tickText;
+ ctx.font=tickPx+'px sans-serif';
+ ctx.textAlign='left';
+ ctx.textBaseline='middle';
+ // Extent above/below the middle baseline plus a 2 px margin, so an
+ // antialiased edge never reaches the canvas edge; the fallback is half
+ // the size.
+ const extent=t=>{const m=ctx.measureText(t);
+ return {asc:(m.actualBoundingBoxAscent||tickPx*0.5)+2,
+ desc:(m.actualBoundingBoxDescent||tickPx*0.5)+2,
+ w:m.width};};
+ const hi=extent(hiText), lo=extent(loText);
+ const top=hi.asc, bottom=imgH-lo.desc;
+ if(bottom>=top){
+ const clampY=y=>Math.min(Math.max(y,top),bottom);
+ let yHi=clampY(_vToY(dMax)), yLo=clampY(_vToY(dMin));
+ const need=hi.desc+lo.asc; // no overlap between the two
+ if(yLo-yHibottom){yLo=bottom; yHi=bottom-need;}
+ }
+ // Both fit, or the maximum alone at its own clamped place — never a
+ // maximum pushed off the top to make room for a minimum that then
+ // is not drawn either.
+ const both=yHi>=top&&yLo<=bottom;
+ ctx.fillText(hiText,cbStripW+3,both?yHi:clampY(_vToY(dMax)));
+ if(both) ctx.fillText(loText,cbStripW+3,yLo);
+ const textW=Math.max(hi.w,lo.w);
+ labelCentre=Math.min(labelCentre,cbStripW+3+textW+4+labelW/2);
+ }
+ }
+
+ // Colorbar label (rotated −90°, centred right of the values)
if(cbLabel){
ctx.save();
- ctx.translate(cbStripW + (cbW - cbStripW) / 2 + 1, imgH/2);
+ ctx.translate(labelCentre, imgH/2);
ctx.rotate(-Math.PI/2);
ctx.textBaseline='middle';
ctx.fillStyle=theme.unitText;
@@ -9181,13 +9313,14 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const hasPhysAxis = st && (st.is_mesh || st.has_axes)
&& st.x_axis && st.x_axis.length >= 2
&& st.y_axis && st.y_axis.length >= 2;
- const cbW = _cbWidth(st);
+ const availW = hasPhysAxis ? pw - PAD_L - PAD_R : pw;
+ const cbW = _cbWidth(st, availW);
const padT = _padT(st);
const imgX = hasPhysAxis ? PAD_L : 0;
const imgY = hasPhysAxis ? padT : 0;
- const imgW = Math.max(1, (hasPhysAxis ? pw - PAD_L - PAD_R : pw)
- - (cbW ? cbW + _cbGap(st) : 0));
+ const imgW = Math.max(1, availW - (cbW ? cbW + _cbGap(st) : 0));
const imgH = hasPhysAxis ? Math.max(1, ph - padT - PAD_B) : ph;
+ p._cbW = cbW; p._cbTickW = _cbTickW(st, availW);
// Update stored dims so event handlers stay consistent during CSS resize
p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH;
@@ -9214,8 +9347,10 @@ fn fs(in : VsOut) -> @location(0) vec4 {
_szCSS(p.xAxisCanvas, imgW, PAD_B);
}
if (p.cbCanvas && p.cbCanvas.style.display !== 'none') {
- p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = imgY + 'px';
- _szCSS(p.cbCanvas, cbW || 16, imgH);
+ const fit = _cbFitRect(st, imgW, imgH);
+ p._cbH = fit.h;
+ p.cbCanvas.style.left = (imgX + imgW + _cbGap(st)) + 'px'; p.cbCanvas.style.top = (imgY + fit.y) + 'px';
+ _szCSS(p.cbCanvas, cbW || 16, fit.h);
}
} else if (p.kind === '3d') {
_szCSS(p.plotCanvas, pw, ph);
@@ -10027,7 +10162,11 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const hasPhysAxis = (st.is_mesh || st.has_axes)
&& st.x_axis && st.x_axis.length >= 2
&& st.y_axis && st.y_axis.length >= 2;
- const cbW = _cbWidth(st);
+ // The same narrow-cell rule the on-screen layout applies, phrased for a
+ // panel whose image is exactly iw wide: reserve the values only when the
+ // native image is wide enough to keep them.
+ const full = 16 + _cbTickW(st) + _cbLabelW(st);
+ const cbW = _cbWidth(st, iw + full + _cbGap(st));
return {
iw, ih,
pw: iw + (cbW ? cbW + _cbGap(st) : 0) + (hasPhysAxis ? PAD_L + PAD_R : 0),
diff --git a/anyplotlib/tests/baselines/imshow_labels.png b/anyplotlib/tests/baselines/imshow_labels.png
index 3ef1dd1f2..7a4c06c5f 100644
Binary files a/anyplotlib/tests/baselines/imshow_labels.png and b/anyplotlib/tests/baselines/imshow_labels.png differ
diff --git a/anyplotlib/tests/test_plot2d/test_colorbar_values.py b/anyplotlib/tests/test_plot2d/test_colorbar_values.py
new file mode 100644
index 000000000..4b0d6bfe8
--- /dev/null
+++ b/anyplotlib/tests/test_plot2d/test_colorbar_values.py
@@ -0,0 +1,298 @@
+"""
+The colorbar says how much, not only which way.
+
+The strip drew a gradient, two white marks at display_min / display_max and a
+rotated label, and nothing else — so a map labelled "strain (%)" still never
+said whether its red was 0.2 % or 2 %. The display range is now written
+beside the marks, in the same format the axis ticks use, in a gutter the
+image gives up so the numbers never clip.
+
+The gutter is budgeted for 7 characters (all of ``fmtVal``'s ordinary output)
+so the image does not wobble as the contrast handles are dragged, grows for
+the rare longer strings, and is dropped in a cell too narrow to keep 40 px of
+image beside it. ``plot_box`` mirrors every one of those rules.
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib._base_plot import (
+ COLORBAR_MIN_IMAGE_WIDTH, _colorbar_value_width, colorbar_texts,
+)
+
+FIG_W, FIG_H = 400, 300
+STRIP_W = 16
+VALUE_GUTTER = 45 # 7 characters × 0.6 × the 10 px tick size, plus 3 px
+
+
+def _colorbar_figure(lo=-1.5, hi=1.5, label=None, size=(FIG_W, FIG_H), **extra):
+ fig, ax = apl.subplots(1, 1, figsize=size)
+ # viridis: BOTH ends of the strip differ from the panel background (the
+ # white end of a gray strip does not), so the strip reads as ink top to
+ # bottom and its rows can be found.
+ plot = ax.imshow(np.linspace(lo, hi, 32 * 32, dtype=np.float32)
+ .reshape(32, 32), cmap="viridis")
+ plot.set_clim(lo, hi)
+ plot.set_colorbar_visible(True)
+ if label:
+ plot.set_colorbar_label(label)
+ for name, value in extra.items():
+ getattr(plot, name)(value)
+ return fig, plot
+
+
+def _ink(img):
+ """Boolean mask of pixels that differ from the panel background."""
+ rgb = img[..., :3].astype(int)
+ background = rgb[2, 2]
+ return np.abs(rgb - background).sum(axis=-1) > 30
+
+
+def _longest_run(mask):
+ """``(first, last)`` of the longest contiguous run of True in *mask*
+ (``(0, -1)`` when there is none)."""
+ best, start = (0, -1), None
+ for i, on in enumerate(list(mask) + [False]):
+ if on and start is None:
+ start = i
+ elif not on and start is not None:
+ if i - start > best[1] - best[0] + 1:
+ best = (start, i - 1)
+ start = None
+ return best
+
+
+def _strip_columns(ink):
+ """``(left, right)`` columns of the colorbar strip.
+
+ Scanning rows from the middle outward (a short strip in a squat cell may
+ not reach the middle row), the LAST run of ink on a row that is the
+ strip's width AND whose columns hold a contiguous tall run of ink — a
+ cluster of glyphs that happens to be 16 px wide is a few rows tall, the
+ strip is at least a dozen."""
+ height = ink.shape[0]
+ middle = height // 2
+ order = sorted(range(height), key=lambda r: abs(r - middle))
+ for r in order:
+ row = np.where(ink[r])[0]
+ if not row.size:
+ continue
+ breaks = np.where(np.diff(row) > 1)[0]
+ starts = np.concatenate([[row[0]], row[breaks + 1]])
+ ends = np.concatenate([row[breaks], [row[-1]]])
+ for start, end in zip(starts[::-1], ends[::-1]):
+ if abs((end - start + 1) - STRIP_W) > 2:
+ continue
+ first, last = _strip_rows(ink, start, end)
+ if last - first + 1 >= 12 and first <= r <= last:
+ return int(start), int(end)
+ raise AssertionError(f"no {STRIP_W} px strip found")
+
+
+def _strip_rows(ink, left, right):
+ """``(top, bottom)`` of the strip: the longest contiguous run of rows with
+ ink in its columns (axis text below the strip is a separate run)."""
+ return _longest_run(ink[:, left:right + 1].any(axis=1))
+
+
+def _value_gutter(ink, width=VALUE_GUTTER):
+ """The ink right of the strip, restricted to the strip's own rows."""
+ left, right = _strip_columns(ink)
+ top, bottom = _strip_rows(ink, left, right)
+ return ink[top:bottom + 1, right + 1:right + 1 + width], (top, bottom)
+
+
+class TestColorbarTexts:
+ """The Python mirror of the renderer's ``fmtRange``, pair for pair: one
+ format for both ends, decimals from the span, exponents from the larger
+ magnitude, ties rounded away from zero like JavaScript."""
+
+ @pytest.mark.parametrize("low, high, expected", [
+ (-1.5, 1.5, ("-1.5", "1.5")),
+ (-1.25, 1.25, ("-1.25", "1.25")), # the ends exactly, not "1.3"
+ (-0.005, 0.02, ("-0.005", "0.02")), # NOT "-5.0e-3" beside "0.02"
+ (1.9e-4, 0.9995, ("0", "1")),
+ (0.0, 1234.0, ("0", "1234")),
+ (0.999, 1.001, ("0.999", "1.001")),
+ (0.2, 0.6, ("0.2", "0.6")),
+ (5.0, 5.0, ("5", "5")),
+ (-0.001, 0.001, ("-1.0e-3", "1.0e-3")),
+ (12500.0, 20000.0, ("1.3e+4", "2.0e+4")), # 12500 ties away, like toExponential
+ (-1.5e-10, 1.5e-10, ("-1.5e-10", "1.5e-10")),
+ (-1.2e308, 1.2e308, ("-1.2e+308", "1.2e+308")),
+ (0.0, 0.0, ("0", "0")),
+ ])
+ def test_matches_the_renderer(self, low, high, expected):
+ assert colorbar_texts(low, high) == expected
+
+ def test_a_non_finite_end_has_no_text(self):
+ assert colorbar_texts(float("nan"), 1.0) == ("", "")
+ assert colorbar_texts(0.0, float("inf")) == ("", "")
+
+
+class TestGeometry:
+ def _wide_image(self, size=(FIG_W, FIG_H)):
+ fig, ax = apl.subplots(1, 1, figsize=size)
+ return ax.imshow(np.zeros((8, 64), dtype=np.float32))
+
+ def test_the_value_gutter_comes_out_of_the_image(self):
+ # A wide image, so width is the binding constraint (a square one in
+ # this panel is height-limited and would not move).
+ plot = self._wide_image()
+ before = plot.plot_box()["width"]
+ plot.set_colorbar_visible(True)
+ assert before - plot.plot_box()["width"] == STRIP_W + VALUE_GUTTER + 6
+
+ def test_the_gutter_grows_with_the_tick_size(self):
+ plot = self._wide_image()
+ plot.set_colorbar_visible(True)
+ small = plot.plot_box()["width"]
+ plot.set_tick_label_size(14)
+ assert plot.plot_box()["width"] < small
+
+ def test_an_ordinary_contrast_drag_does_not_move_the_image(self):
+ # Every value fmtVal writes for ordinary data fits the 7-character
+ # budget, so dragging the handles must not change the layout.
+ plot = self._wide_image()
+ plot.set_colorbar_visible(True)
+ plot.set_clim(-1.5, 1.5)
+ steady = plot.plot_box()["width"]
+ for lo, hi in [(-0.1234, 0.1234), (0.0, 1234.0), (-12.34, 0.5), (-1.2e5, 1.2e5)]:
+ plot.set_clim(lo, hi)
+ assert plot.plot_box()["width"] == steady, (lo, hi)
+
+ def test_a_long_value_widens_the_gutter_instead_of_clipping(self):
+ plot = self._wide_image()
+ plot.set_colorbar_visible(True)
+ plot.set_clim(-1.5, 1.5)
+ ordinary = plot.plot_box()["width"]
+ plot.set_clim(-1.5e-10, 1.5e-10) # "-1.5e-10": 8 characters
+ assert _colorbar_value_width(plot._state) == 51
+ assert ordinary - plot.plot_box()["width"] == 6
+
+ def test_a_narrow_cell_drops_the_values_and_keeps_the_strip(self):
+ # 100 px wide: strip + values + gap would leave 33 px of image, under
+ # the 40 px floor — so the values go, and the image keeps 78 px.
+ plot = self._wide_image(size=(100, 200))
+ plot.set_colorbar_visible(True)
+ box = plot.plot_box()
+ assert box["width"] == 100 - 6 - STRIP_W
+ assert box["width"] >= COLORBAR_MIN_IMAGE_WIDTH
+
+
+class TestRendering:
+ def test_the_range_is_written_at_the_ends_of_the_strip(self, take_screenshot):
+ fig, _plot = _colorbar_figure()
+ gutter, _ = _value_gutter(_ink(take_screenshot(fig)))
+ rows = np.where(gutter.any(axis=1))[0]
+ assert rows.size, "no value text beside the strip"
+ h = gutter.shape[0]
+ assert rows[0] < h * 0.25, "the maximum is not written near the top"
+ assert rows[-1] > h * 0.75, "the minimum is not written near the bottom"
+ # Two numbers, not a smear: the middle of the gutter stays empty.
+ assert not gutter[int(h * 0.35):int(h * 0.65)].any()
+
+ def test_the_numbers_are_strictly_inside_the_strip_height(self, take_screenshot):
+ # A glyph cut off at the canvas edge leaves ink ON the edge row; an
+ # intact one has a clear row above its ascender and below its
+ # descender. The strip paints every row, so only the text can tell.
+ fig, _plot = _colorbar_figure()
+ ink = _ink(take_screenshot(fig))
+ left, right = _strip_columns(ink)
+ top, bottom = _strip_rows(ink, left, right)
+ text_rows = np.where(ink[:, right + 1:right + 1 + VALUE_GUTTER].any(axis=1))[0]
+ assert text_rows[0] > top and text_rows[-1] < bottom
+
+ def test_a_tiny_range_keeps_the_two_numbers_apart(self, take_screenshot):
+ # The raw band stays the full ±1.5 the pixels were encoded over; only
+ # the DISPLAY window collapses, so the two marks land on top of each
+ # other in the middle of the strip and the text has to be held apart.
+ fig, plot = _colorbar_figure()
+ plot.set_display_window(-0.001, 0.001)
+ gutter, _ = _value_gutter(_ink(take_screenshot(fig)))
+ rows = np.where(gutter.any(axis=1))[0]
+ h = gutter.shape[0]
+ assert h * 0.2 < rows[0] and rows[-1] < h * 0.8, "the text left the middle"
+ gaps = np.where(np.diff(rows) > 1)[0]
+ assert gaps.size >= 1, "the two values were drawn on top of each other"
+
+ def test_a_long_value_moves_the_strip_by_the_extra_gutter(self, take_screenshot):
+ # "-1.2e-100" is 9 characters: the layout must reserve two more than
+ # the 7-character budget, which shows as the strip sitting that much
+ # further left of the panel edge — and the glyphs stay inside.
+ fig, plot = _colorbar_figure(lo=-1.5, hi=1.5)
+ before = _strip_columns(_ink(take_screenshot(fig)))[0]
+ plot.set_display_window(-1.2e-100, 1.2e-100)
+ ink = _ink(take_screenshot(fig))
+ left, right = _strip_columns(ink)
+ extra = _colorbar_value_width(plot._state) - VALUE_GUTTER
+ assert extra == 12
+ assert abs((before - left) - extra) <= 1, "the layout did not grow for the long values"
+ values = ink[:, right + 1:right + 1 + VALUE_GUTTER + extra]
+ value_cols = np.where(values.any(axis=0))[0]
+ assert value_cols.size and value_cols[-1] < VALUE_GUTTER + extra - 1
+
+ def test_the_strip_is_coloured_through_the_display_window(self, take_screenshot):
+ # The image maps 0.2..0.6 onto the whole colormap; the strip must too,
+ # saturating beyond the window, or the numbers sit beside colours the
+ # image never uses.
+ fig, plot = _colorbar_figure(lo=0.0, hi=1.0)
+ plot.set_display_window(0.2, 0.6)
+ img = take_screenshot(fig)[..., :3].astype(int)
+ ink = _ink(img)
+ left, right = _strip_columns(ink)
+ top, bottom = _strip_rows(ink, left, right)
+ column = img[top:bottom + 1, (left + right) // 2]
+ h = column.shape[0]
+ above, below = column[: int(h * 0.35)], column[int(h * 0.85):]
+ # viridis ends: yellow (253, 231, 37) above the window, purple
+ # (68, 1, 84) below it.
+ assert ((above[:, 0] > 235) & (above[:, 2] < 80)).all(), \
+ "above the window the strip is not saturated yellow"
+ assert ((below[:, 0] < 100) & (below[:, 2] > 60)).all(), \
+ "below the window the strip is not saturated purple"
+
+ def test_a_short_strip_shows_the_maximum_alone_and_whole(self, take_screenshot):
+ # 70 px tall with axes on leaves a 16 px strip: room for one value,
+ # not two. The maximum is drawn, at its own place, and never cut off.
+ fig, ax = apl.subplots(1, 1, figsize=(300, 70))
+ x = np.linspace(0.0, 1.0, 32)
+ plot = ax.imshow(np.linspace(-1.5, 1.5, 32 * 32, dtype=np.float32)
+ .reshape(32, 32), cmap="viridis", axes=[x, x], units="nm")
+ plot.set_clim(-1.5, 1.5)
+ plot.set_colorbar_visible(True)
+ ink = _ink(take_screenshot(fig))
+ left, right = _strip_columns(ink)
+ top, bottom = _strip_rows(ink, left, right)
+ assert bottom - top < 24, "the strip is not short in this cell"
+ rows = np.where(ink[:, right + 1:right + 1 + VALUE_GUTTER].any(axis=1))[0]
+ assert rows.size, "no value drawn on the short strip"
+ assert np.diff(rows).max() == 1, \
+ "two values were squeezed onto a strip with room for one"
+ assert rows[0] > top and rows[-1] < bottom, "the lone maximum is cut off"
+
+ def test_a_narrow_cell_draws_the_strip_without_values(self, take_screenshot):
+ fig, _plot = _colorbar_figure(size=(100, 200))
+ ink = _ink(take_screenshot(fig))
+ # Only the gap and the panel's own border lie right of the strip now;
+ # look inside the strip's rows and short of that border.
+ gutter, _ = _value_gutter(ink, width=6)
+ assert not gutter.any(), "values drawn where the layout dropped them"
+
+ def test_the_label_sits_right_of_the_numbers(self, take_screenshot):
+ fig, _plot = _colorbar_figure(label="strain (%)")
+ ink = _ink(take_screenshot(fig))
+ _left, right = _strip_columns(ink)
+ # (That it also FITS the panel is test_no_clipping's job.)
+ assert ink[:, right + 1 + VALUE_GUTTER:].any(), \
+ "no label ink beyond the value gutter"
+
+ def test_an_rgb_image_still_draws_no_strip(self, take_screenshot):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ plot = ax.imshow(np.full((32, 32, 3), 200, dtype=np.uint8))
+ plot.set_colorbar_visible(True)
+ ink = _ink(take_screenshot(fig))
+ row = np.where(ink[ink.shape[0] // 2])[0]
+ assert np.diff(row).max() == 1, "an RGB image grew a colorbar"
diff --git a/upcoming_changes/71.new_feature.rst b/upcoming_changes/71.new_feature.rst
new file mode 100644
index 000000000..94ace942e
--- /dev/null
+++ b/upcoming_changes/71.new_feature.rst
@@ -0,0 +1 @@
+The 2-D colorbar now writes its display minimum and maximum beside the strip — both ends in one format, and the strip itself coloured through the display window (saturated beyond it, the way the image is) and spanning the image rather than the whole panel — so a labelled scale says how much and not only which way; the image gives up a value gutter budgeted for the numbers (fixed for ordinary values so a contrast drag never moves the image, wider for the rare long ones, dropped in a cell too narrow to keep 40 px of image, and mirrored in :meth:`plot_box`) so they never clip.