From 6b4f8257141528a75b49bff0021451b2c6745573 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 12 Sep 2026 05:52:11 -0500 Subject: [PATCH 1/3] Write the display minimum and maximum beside the 2-D colorbar The strip drew a gradient, two white marks at display_min / display_max and a rotated label, and nothing else. A map labelled "strain (%)" therefore still never said whether its red was 0.2 % or 2 %; the only place the range appeared was a host application's own histogram widget, which an exported figure does not carry. drawColorbar2d now writes the two values beside their marks, in the same fmtVal format the axis ticks use, kept inside the strip's height and held apart when a tiny display range would stack them. They need room, and the layout is computed before anything is drawn, so the gutter is a fixed round(3.6 * tick_size) + 3 px (_cbTickW) rather than a measured text width; _cbWidth reserves it and plot_box mirrors it, so Python-side geometry stays exact. The rotated label moves right of the values. The one visual baseline with a colorbar (imshow_labels) is regenerated: the image is narrower by the gutter and the strip now reads 1.9e-4 .. 0.9996. --- AGENTS.md | 2 +- anyplotlib/FIGURE_ESM.md | 147 +++++++++--------- anyplotlib/_base_plot.py | 6 +- anyplotlib/figure_esm.js | 45 +++++- anyplotlib/tests/baselines/imshow_labels.png | Bin 24671 -> 22192 bytes .../tests/test_plot2d/test_colorbar_values.py | 124 +++++++++++++++ .../+colorbar-values.new_feature.rst | 1 + 7 files changed, 243 insertions(+), 82 deletions(-) create mode 100644 anyplotlib/tests/test_plot2d/test_colorbar_values.py create mode 100644 upcoming_changes/+colorbar-values.new_feature.rst diff --git a/AGENTS.md b/AGENTS.md index 8f35c9e2d..ebd32ba73 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,244 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..595310c19 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. ``` @@ -52,29 +52,29 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | 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 | +| **2D gutter geometry**: `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 303 / 311 / 323 / 333 / 343 | +| **Layout engine** `applyLayout` | 834 | +| `_buildCanvasStack` | 917 | +| `_createPanelDOM` | 1059 | +| `_createInsetDOM` / `_applyAllInsetStates` | 1200 / 1594 | +| `_resizePanelDOM` | 2307 | +| **2D drawing**: `_imgFitRect` | 2471 | +| `draw2d` | 2800 | +| `drawScaleBar2d` / `drawColorbar2d` | 2995 / 3275 | +| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3094 / 3117 / 3130 | +| `_drawAxes2d` (ticks, labels, title) | 3352 | +| `drawOverlay2d` / `drawMarkers2d` | 3505 / 3669 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2620 / 2644 / 2705 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 790 / 821 | +| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4513 / 4595 | +| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4635 / 4650 / 4671 | +| **3D drawing**: `draw3d` | 5647 | +| Event emission `_emitEvent` | 6484 | +| 3D event handlers `_attachEvents3d` | 6541 | +| **1D drawing**: `draw1d` | 6765 | +| `_drawLine` (1D series + markers) | 6918 | +| `drawOverlay1d` / `drawMarkers1d` | 7211 / 7295 | +| Marker hit-test `_markerHitTest2d` | 7563 | > **`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` | 7820 | +| 2D events `_attachEvents2d` | 7862 | +| 1D events `_attachEvents1d` | 8255 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8530 / 8809 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8722 / 8736 / 8765 / 8800 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8934 / 9007 | +| Shared-axis propagation `_getShareGroups` | 9078 | +| Figure resize `_applyFigResizeDOM` | 9142 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9333 / 9396 / 9772 | +| Generic redraw `_redrawPanel` | 9962 | +| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10121 / 10317 / 10376 | +| Native-resolution render `_withNativeSize` | 10097 | +| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10410 / 10519 / 10698 | +| Export registry `registerExportAction` | 10575 | +| **Embedding API**: `createLocalModel` / `mount` | 11089 / 11145 | +| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11400 / 11787 | > **`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,8 @@ 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. | +| `_cbTickW(st)` | Width of the value gutter right of the strip, where `display_min` / `display_max` are written: `round(3.6 * tick_size) + 3`. Fixed rather than measured so the layout is known before drawing and Python's `plot_box` can mirror it. | +| `_cbWidth(st)` | Width reserved for the colorbar: 0 when hidden, else `16 + _cbTickW + (label ? label_size+8 : 0)`. 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 +280,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)`** | **2471** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | +| `draw2d(p)` | 2800 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | +| `drawScaleBar2d(p)` | 2995 | Physical scale bar | +| `_rawBand(st)` / `_buildLut32(st)` | 2520 / 2527 | 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)` | 3275 | Gradient strip + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtVal`, kept inside the strip height and held apart on a tiny range) + rotated label centred in the gutter right of the values | +| `_drawAxes2d(p)` | 3352 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | +| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3505 / 3669 | 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 +639,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` | 9997 | inverse of `_applyScale`'s `transform:scale()` | +| `_panelBox` | 10008 | the element whose rect bounds one panel | +| `_neutralizeView` / `_restoreView` | 10017 / 10042 | transient whole-extent view | +| `_nativeGeom` / `_nativeGuard` | 10057 / 10072 | native size + why-not message | +| `_withNativeSize` | 10097 | resize → redraw → run → restore | +| `_compositeCanvas` | 10121 | the compositor (`_drawEl` / `_drawPanel` …) | +| `exportCanvas` / `exportPNG` | 10317 / 10376 | 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 +741,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` | 10410 | transient bottom-centre message | +| `_copyCanvas` | 10445 | clipboard write + feature detection | +| `_showPngPreview` | 10469 | framed-document download fallback | +| `_downloadCanvas` | 10519 | `` or the preview | +| `registerExportAction` | 10575 | downstream extension point | +| `_menuRows` / `_openMenu` | 10629 / 10698 | menu model / DOM | +| `_panelAtPoint` | 10810 | 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 +831,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` | 11400 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | +| `dense` | 11426 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | +| `ragged` | 11491 | the same three, over a row-pointer block (`offsets` + one array per column) | +| `maskFromWidget` | 11574 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | +| `rasterDisks` | 11614 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | +| `robustLevels` / `toU8` | 11643 / 11684 | the percentile window and the 8-bit code map, one implementation | +| `panelAxis` | 11762 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | +| `installTouchShim` / `reportEmbedHeight` | 11699 / 11718 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | +| `encodeBase64` / `typedArrayBytes` | 11738 / 11746 | a 3-D cloud's geometry channel is base64, not the binary side table | +| `mountNavigated` | 11787 | 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..a8195bc01 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -376,14 +376,16 @@ 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. 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) \ if label else 0 + tick_w = round(3.6 * (self._state.get("tick_size") or 10)) + 3 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) + 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..066a4f04d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -295,14 +295,24 @@ 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. + // Width of the gutter the display_min / display_max values are written in, + // right of the strip. Fixed rather than measured so the layout is known + // before anything is drawn (and mirrored in Python's plot_box): fmtVal + // yields at most ~6 characters ("-0.012", "1.2e+4"), about 0.6 em each at + // the tick size, plus a 3 px gap off the strip. + function _cbTickW(st) { + return Math.round(3.6 * ((st && st.tick_size) || 10)) + 3; + } + + // 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) { if (!st || !st.show_colorbar || st.is_rgb) return 0; const labelW = st.colorbar_label ? Math.round((st.colorbar_label_size || 10) + 8) : 0; - return 16 + labelW; + return 16 + _cbTickW(st) + labelW; } // Gap between the right edge of the image and the colorbar strip. Without a @@ -3303,10 +3313,33 @@ function render({ model, el, onResize, onReadout }) { ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbStripW,_vToY(dMax));ctx.stroke(); ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbStripW,_vToY(dMin));ctx.stroke(); - // Colorbar label (rotated −90°, centred in the label gutter) + // The values at those marks, so the strip says how much and not only + // which way. Same format as the axis ticks; kept inside the strip's + // height, and held apart when a tiny display range would stack them. + const tickW=_cbTickW(st); + if(dMin!=null&&dMax!=null){ + const tickPx=st.tick_size||10; + const half=tickPx*0.5+1; + const clampY=y=>Math.min(Math.max(y,half),imgH-half); + let yHi=clampY(_vToY(dMax)), yLo=clampY(_vToY(dMin)); + if(yLo-yHiA`xYX7IlmgEkyL*LIe>lF$kH_Ta;+g z2Z>%2L5O(IxbNqAzVGMvhd;uZbLQ-8Uu*BZ*18gV%jhO087mnG1ftZ{(Yg%+feZ0J zqy)fk9L@NtKp=B}U9D?o0pOiHXam1?+FvUwc2&2VEhg8Zuvki#WLhmJ6Nn`}6El|K z#(7BuSt7C!>86Y%MRUh+=ZMSj638EMw_G1fD2K~5Ad#OXJLI|JpOs$wySN2^cU5%L z+1dY7HB;K}khQ$y-T1ivxQ|%}6VG#w7czfr*AEZnsT_~q3IFkuYB1egxh0?`J()+|8E? zBdA#S%p}`R9DY%tRDOR;d90k|Pt#~cczqHH_2?y&I#{W0-mEYVI~{AB^NuGLnt{y= zNSdBa{)9iCtm=%o<{FC|x9{QbHwZbJcHwH3_SGSo4n5oP9rSszvwF?@^sZW0rod*q zqiY86B0J6bMBM@`@FnkoZLrgb2T}qhLuGfq(0&lF4Ik?48Pq&oHq*Wqm(OY5{@CF*av(?CVa4 zO-$G?$yNnsiG%wVs6e@C|F=_bjS=3!=W?vinGQDi6{j5;hwEG;A@x7 z!jg^F5E7+t-C)K!hP&3aQ>yI2``E_C(3ZbzsH zqcY2oA|f8DX<4{?R|`T=YQPgV@;xY8)x;GI#SA6iKP)c&nCi1nzxQ@gnldPGC`E7 zwcX=SZ>KcW$vFepd3GQ(G=V`hxx%_B`Vz~0wJ!zlE2B@0l)7}y3nJG=TwA`q)JJuV zOIn^ggZhHm1jvoF%2Fs$`WN|un{Vn}hK&pquAowgQ<{T9bN>6avj|R)u_Rn>Cz03Y zJEKFOlHxKPw;(;Nvaz@??DWu00b1*d*M&oYcccW?ZGCl=!d?n7MM$8%NsaWjE5+%o zhv)agtlotO^BRz1V~g5RV>cgm3p_E(tK*_Kp|&d)v|HmWw5#uZX^KCOG%H zhe$TAT=0#0R!_D(jfl4=<{wQXkagVk#>{{{H5GT}tZ!sgCHJgWj!S$&tFN{~u0F)Vi?sai~{3JG; z>#dxGAiCO+vcQw=$_u&|q`S4`%$mGO({PWGj%kThk6raSbKjJPJ^j~=TTE>8_ zozc1);|)Cd`2yru43iPzZ?}&ti7Khc2^xp%QYKG07-t+%R_MNE|D=2%JpbHA%H-zW z>QT(w@L-w8dqM(sa*Rw#&T zocGI7tmH93G!YEAOaFVZ8$MPh@ga!&Ijri7@(+P;*SHW^`Gby?x7I%N#nJmr+zg`S zjJ>%mJU6!C?wh*zteS4+U2gyUsaakwxXpb;V@T_XN7MJAmR;8qzW+pD9W8HQSL<(0M)6VV@I^&-bcGAH`KR-$HWOcBX=<dU zB}LP)W#har!@#!C`9mnJC_Gp+4tMC*@PFQpC1t3D`G4^_!$td>o~BfV$9~aJ&+=^E z5F`q_5-D++Q(sXz<&RcY65@z|Ob4tyR}eiZ;pvRFDPFzI76s9-|21~r^MeV zn$2URBqAD){Z1y{*aew-uDetJCkOxd`Cn6u0b_=gVb!0Oh^-J!ROG;#{P%;Sz;m@E zj=sus65T~$#CZ6iQ9&f5~2g67r2OT!flBd5S0MGj1CrzuXt7}5#; zQYkVhaw@`3;QJK&{0L=a2m8ey=4SY(cl)Mxwsz!p3P_$euFC9M-e zxU0jA;EDzxCqSu)=R7-tQ{b8q|DE?!-Ocue+(6c)^*V?2@S{2$JY(WwjbBH zfe(R_AX;mLrY|@ChE~)E5o(05ynuRE`E7G80ili}(?PI^7S`mLvAA<7BZ>od-uptAYJ%7x; z|6%?7TssoebE2cLO$%M>UB*-sGZNzL<``>fq?Ew8`c+x5q9~U&QQ=jO&4xDzbkvff zQe$5ir9j4@41yd&c8aSpZ&voBcZl{T%@N+}=R;%{pndM(z)&)beqYzwFj&3Vn%aT^ z!(*((7T_MW1G0IHUZGN_w;_x;94` zc89PE1P>&CboH4kHPUwd%RW;GVE~iH7!vG{NUH)f^%pG?a}wD=xN~>o*T9v8TyJQG zq@WaM7~OZEtHXX(1wWr6sS4x6Tfk_XIX1wyL6v5Dz6V6QI=dnVk|Qo{i#OJx;YR<= z$9xC<$S{BPfp42ua#C!G`cJzxG6QyZ)nwiX+_|%(cfnD;*=Ve~@#-Y}$L<3IYi9GM zy3~CSWPaxb=5a9~IQ%(UO@0HVbEf2$>#&+!fCI z-XVkSOXYhzi`pt965^{?GC(M$&;#{A7F!zSnvqdXt23@eL!FHyE3FT<;8ipaER^_3 z2U5=+sN9ru3^R##8F=WjuqY2Y=N5NwfML+`W+vGL$1_g{S+v+EDq?h0Qv6-3-m~<@ z{+Z%v4-y7j7A1v2(V)V{hfGNOhgXRgz!+zLuK@WCruaH-*!stgwe5DaQa&j!6SYeK zpT21AH>i91tY~4)h&_eSt9q#$lHnxRi1$MeTWVFYSHYEFRWIQ)lumxs<6|}-uP=G0 zez;#CDMNK~=C?|>Cf_>$gJMu4h&_r<_s$=`9B6g^iUFb&k(CgY>Qd38E2Df$H2`rz z=?hw9Koh%HH7pNZ$g8EYDBhdAcj$b4`qo(ODd%bEM<5|wP;_q&M)g{&h6K@VPBM?4 z%Y4`J3b|?n=`mA}rkbaMi~Xbkjq2vK_c;Wmn01}MHY`b+W^$W5IpR>a+yOAzzy*xyM3yAh`NP_)JLRQ zK{mxNZwneSzJJ~*^IUqPI2%YW4k5+QC{k}$;TTFo#5L{|!QLkye@$B5e7|szX_S#} z@9Z-Nm)x)o|CVC(DU4KWh@1T91*sjG&{fh$C6y)7Ux!&zZx#$3VX`rV82#|xJm+yv znAn=bF{)8UPUvGCY`xoo=*mk+$eb?jOWID60Q6HiW}fgxdY}QifWM}gg^#k*G2vX> zY}(bQV22w1I_<8g>Z9@*|0`naUSY(_ly6ve*FZ_r8COZP<~5*SNf8{JDc5el-`g=d-AQ-fmRfn4Nx^n-2(tmf&37%3wjY8u1`FSQJ*Q^)a^$2ubPp2` z-ljhAROQxdmKcr;@DXn}MyuU6S*)d{xrRE-7@fMo#cp(JVS|eKk)lC4mc|J7hb@rU zJ%0B2)4lLEdE)*W_Sq@qYmi{yk9>P;HYtS;p4yg;=md*|uLg`o5h}aG31m)Q$_vy% zK%;osw(08!1Fy3`dk@#hl!I&5L}b_|YGx5?g}k0YZRy7d$j_isL|lNMyDSLXokAjo zC#weP3Dp@B(~!Ho8NWDEfW4E!SaDwvJs{bVP#qmO4VlW&C3C08dc{+^{e4RC;hl9- zbwl~EKzO63Tat!tUzvvD27gT=8;4{8fh560b9nA^`-e+a$ru7BX`OEL=t{?*Tgqqf zbYp2d{aN)Rix)IGX1axYNja^58wmUZwXfAT%2rX>yn8pgb;yF8I9#`NM6*aq zuQ)nv-S%kcN61p)f^^d3|G0mWDdO@7P=!!M$e zE#HBbxnb*%JDB%MFQTZpcggyT|1RZp2-00GO4>F@dc4tbNJCIz;0<3VO;N7KM43e_ z;Q=rs@bbywy&te((n7ZQtfmD2>&MgwP8$&=$8>4aLrGrJ>Cd}1mW%aqY%E`xUrUAW z!gn38$KFZ_2FoP3p!>gz*v(r?{9h(V^gcaj4?%q7`tkJNhUx^@%aIE#AU8K%Zf12C z-Qf1ac+x~p-tIPOMri5L)dA~vPr_tIRwFv7kMHR91`>?|)-ff^#OK;u7HZG0 z-H8VqT>M!hGq6h__yEhy>1em#r)+buLRw~t+a_jyGHm+lg5u|h@-h}DG9VoJNPjox zP*OT6&Bn+LdB92a(u!-}X~jflVE>$i#+ZkHWYsy6`*xP?=VITFVF4mZB6lftgEW<-=UKsXwL)(_K+L9dhIYrvz zc^C2_M(Z94Am?q{U1N!EE|TRbCTuz|$gVPf$xKqeAce=KN$ZJ$4IX^M(oTM0{u6#I zEtH%ul1hHYO~eZV01>SAc?^$LO z8&T8STgvmFXSjN&cjc>Xx0#HKQo*1%lZGG{81lbaGAFwMo^`9NgjUpf_A(}{%Vo*i@=`3x57}Q`w$dYFM#543{x6J8EZH%KHqkh~Ii9rbgrQWgd3vi}! zH;%2ph!W~U-)Y|l83{sW?-VSBbjj%X)c^mG%K+ifEJ>HE8MJZtZ3d8<&K;U;&ZL?D z6l^uI62p5^@szDv5~)K!?6YtU=5jzJ&?D6uUD1N*TmZ3YvDk?Z%3EtSKhT?fkj!h^ z&qmc4XpHu40;7a~J|8TMYY>GVzE&lMM`(}y^;UcF`Zdiv4p6Zj9vu3zY_67jTG ziE48LT7`=vxqu)O&bA;H&3Jp_P_x~zkw0SF$(b5#1)6!tS0jyS$QKZ;X*i4qaa@^WN_>;&6C{07 z9T&xV)*?!?%MbMlH_Vfl3vDl=TVRJuL6`+^0OSm)Rp&9m;G{KWp(j;e_MXe{4+u^U zQY}_Sg~MRBq1pM!`hAb1V%h^;|E;2_y0xiW&=e3ce`(hRySaTVPl+mNN$v#iy!8N zO62<0sp;`76r-NY?;3pSH{9B(BXU6vWL=Yxuq>_{Vb*`l0XExxU;C*yBv?X~heXTy zN^@xOsUj;Y*KvHG9@EAsn9J?T*ZTbv4muAV%CVL}R=k9ozbJdz03b%rB$gH{k`yzfIbYzi# ztP+m_`FCkr+%KopfR(mVgJGv^p?WV{BKXS7BG z_?tdl_=(__{w%0AtQxWa^U?ZIpMMu@)LX(#_pFuIru&E-?6IG1{D5}u;Ca!|hfCVB zSU~h-N9`Lq5Q=I($ZKXtb@7VJjs5R@|FWJ)$h3NZF<0ig!@2Y=fF-r+&!=|!1;AFIDc-m;W zbimEK8r3`Gw6I2bV@UPKyV|>=HwPl_R@c;iqL7X!wTDtvm8>P76?=z8ClSneUcv0B z@M6;mBm9m-Wog(VbTGA7`|t7m7QYq3uTEK+;k^4aHv5u$&dt1R&=j#8V*sc3-+GPG z_p{D`8Xa*oKCQ$DBturWNaAuZ8v-Vfd;lq0* zB%4a)4ma4NIgf=xdyU)C+0u_vCE12{mP02dWw|@M%DTI=`#vZz+k#s1rIi3&+K&a< z_HqBcuANB!tXLJbDkUyppo#)sXw1++xSzVfX3_}@Se_eQcGTE5DN=j~g8A*)#ju!0 zvz+%V`|NkBU+>|P5=aVT!&Lq}B4gg}If4ZEecUw=DLs<|T9oT!`W8U9eM!8EUov-d zqt2+YMk-W4p5~G=8E|gMPtL+QiQ|j8K}z{$$>wAgIkp9|LQ>*f*#kR~!fGprP1AOn zk-wJ1ITvy~AM#89wPHkc7=mCMdlqg5a$DNJ<)+_1cYE6vL8v18Gs)!sGeQGo>Ghi` zd&HTsZ}ujTn|dVe;k}0jf~~x!Kkx5zNp>aZGyZaUtMI`33(FPCLc)g}+ysZ~0ss@o zjawl-IaAiJmsZ_b0UbG<)6+d@65`Z<6$xY3xcQ!s#h-)|V&&t;zH0sAXrMj_F!4{Vmtf9iq+NEGb*J_Yj%Q_%+frlOg!Y_!D70tuMw#1qJIQ%ySS zWgsG%iE~aU{i;_zv?kTQg?1>hsD9`78}_AS^6QNfie8s3ys2bCfl!_gtda}g+Tz)8 zp(1U2_Im~WF?`rN$xwjcpaP&^(*TuG&S4D@8K+-NNA=B=`cpfZni6x0} z?dR#9&|u)aEJ*N;0O>Z7Id=-WGgaj0tJHt4VCub&*|Rmp??@GW@UHB+{C*@Bt!Cz0*^C^0)ms zb9X4UaonTcr8+I$#&>XVIu-UhD3s>>;UTt`j5B^jI!K{nMHi!6f_CwnQluIr}VwA7}z-W*B^ce&k`0Gg-6+QXD@?M~P)1K)4Y7>t{o34GBp50S*WiX40 z&LYeGUkMtoZoZ62s_=s`H`Biv_JP#zTl>7wcqVh5>M5_9sm6t?Le_P*4oHSr%^XlC zGpYW3cn7VHfV}pUQ8#)6AjH2fgXe?$61VWpz*bSzp{M>b?qQA;30(f=Q_XQA&i<G70at5iX^NO(7KC|Lq}_q0nvsDzI2r!*0@w$z1x)RWzLIye4}2+MlzGspm>6B> zI5Ox8n!;I+960g837o(97iHiKK};JbxfAGmF7orv?=SZ4k=frc4#$sE4Z^aIiH}rr z>Eu2Oz||i6BWG55^Wp;);gY9B8hcu=D7?jV%(PGW^&fb#3=H)9ULi?$qGi8N2J`{{ z3RU8Y3!S(|fPxWJTwnthttqLA!~K$~)!Y#0N{1Siv47fh$NG+-#t5?_@KO19aq-;D z(^yx0$^-1qTPirm%{eB!Tr3ILoHMB(=W|uM8Kn!qfYh)m&iSzM1b&;^gRJK`#(?fS z2GcNN83zp<424!Bz1Gw|jLr7Sd>@QC6{V?2!3fox>A0au)|TX{L1t3#dri#1~KRgXzG{=+q&AJG2a|>W_Geksvu~ZiaI$WiDu7YSPSgxO@ z^Y?K80S0&!(^S%a-m=PHGYwj@`lxlzJ$1{b*dgll4j}YCj8$%iWLOvw8JTV0816BP zd$9CWB5?$UDw&OUMljoHbFrKHWwOyXX;JWHE4#b?8F^m|!DN;hgM!hzP5ptKXZkKQ zj$7!oIV-Wwv=q$Cn}_W7*81Fm+Z|RQc8d2{Iv4P+5GXK@_n|oNFMjxWsVqNpvpirfW0c}f!Y6c+d?R~oV>(Cr4v%VUEGc!{EaPviB zm{Of?s`JBt=UgJ4`ddK&K_%`In_V8kn46$@URk=;Ua!Z8GHnPPdKkNR_@$lNfhWoC zlGc>Pa*QxLbO1(+X%1263g^fGJAyY7wf%M|Q<{i&f}R!b)>l+3t;)H6mzCBGz)1!d zxz$F(ejc?F_U`XGEfls)_H7zzepAx~DaBRPeRs=LfWu-B^^TL{K_ZfQSjxuKDqh;fs zn|UT!6U^KClx~x z293E0LiQvrn|%yc9dot9)_y8i@s*#?GZ^k&i~s2R!$vMrIl~eANJKO{=c@~O5f|Ou zDy}h4G&JbhC2+FwpvI;|2f*V4o7PlVA{GfUdv#JHYMS3Ku7%H}v?&_0))EQn1C6)! zB_((x8TWF>@8@R)F)B&(H!p?09cc1CD}xA0rtsC!+}N@Y{3mji44cFNuX-odfn66r z!92U({ddA~43?j@%14Kl=1urOaqb~5`&qci5V_xlnALDs;V9&b4)--_vLe_r=X<*! z>_Ml%U}rCru9d{=0COl%R<=|Gg$nEQM;+fJ7$}!cP0&qbr%179ktkQ;p;1>)XL5ma zIL@s3MMG-)mdZf?M2I9E0b0Da(6(Tixi6aqF@+CyceW87T6?=8f$vh z^+wUC{hPSSlc@ADUh+#_Z4@ZPM}Xz1s7+-ct34J66EQZ*jPWTVaruCA!}bx^65TB$ zn#x=}5|6`uf%y_-l=AQX4(KfuaILlH#b4q(hcca)2w)k?SA?NKWgl zVU5w(8>Yv5JJT^W@AseRjwlJ*r69v@%&g0h-aTcf?hTP(=>xjh5B1gTPr5Qad;cbj z=?zo0K0j2JmPj47@_t494@vP^F|Z7WnvF}g{Zqxv{F}#_F!*MyE7sO^C`(ZNF;l1(;dBjZ02L^af_jMv6%r z5ud8D>KS{liW(ol6p<-sAd*uV!Z|SOMFplWz~uk<4zR5fwI{ZmYweZv%|>Ggv>LP~ z>down+oA8Cojz>&bFD?;c-O*?5_wI{BI{Eu`7@S6|`OEbR zSE8>O4}<3T(!3Bpj?%j6RM(n~%t5y$<3nW|QJ|&K^p`3M=)eW5E2T`F#d9 z%4Gf!9G|jS`*fy(<~xCsKbZvVlj)GvtS?X;#8u7bik`Q(T8D`GKZQ}sC4fQ(Z&`__ZzjGYbeQV8|R8mcw z;oU}@!3o_279w179d_17?RTSMb&Te^@{tL6iwGS*az$yS4P?cREhG zsfV%6$WGa+##kKsnp8=%Meju@Qk`BomiLQr9jyR7jHU;Daoa9G=(S|i7x zLi56#v7IX4ClCIN7lp}x6XcBaGVn2Z3mv(}Cv_cEYgzf__*j}d*T@(=GcSX& zQ&Y^AZ2x){^Y&Y@W)`Hmqef(X;;=|{u(LmXc1(DkJd6n5#NfP~ z{+%iKCAI241|38~rcFB++0$mCWGJE7cC(IDQu;oR7tmOG#{s0t-C-|n+T6AcXKd_h zpw0u$zj@Rs)4}Fql1JFSGE;qM7R5g#V?Xljh5}S_yE7a$*y^2LUMw%6zhb!w;2!xg z(6wD^qdN8Qt{1GG(B#h`zsvw?_%P6cw9W*M$>qcTk+$lhq_dfv(c4W5z79 zqSdXPPz$s!5=$kZc~=UG1fP|1tWY>tKUv^M_NDSP%t>1C7m+Jc#fl_kz_z+Hiv2rK zW#Nc#TW#JV#cm8l$B#AW<>d;v7R||fTKqFv0R-jDalU?BU=t0Bji53h_H@IdKre=f z)x7-X)7Xml6&dAGQYP96pO3(X#qY&+Uk7zfMs`)l*}11v_YXSGCBwk*>nX-_=I=mK zV_cbG{Q>vX0-*4@1S8C2WQ`QRozR!>9b)`TjUa!Xn8Qcr==gp~*!se9A~=S#C6aAy zzlw!~324tjYJqS|apR>$=sQRvWL;Y`^=q>MSDAi1t^HhYX2h#A@t5`jT5$k6?NSWf z3pXHnkS@s)mXKDGlNPQhKHW{z8$*-2zeVJ>xHAN0_1injkWyA&X^}MLXu@R;vd2D-*C#CIQ*EkA8l7x|C^juV>R{JiP z(beri;2ZaA5nov}wCr>X7oWHE*boUHOKJ?Seg^@k64b53sah;SZ)1OfJ-jdya?Csd zgczAHAHg~e8|yK5u<&;4VRBLSVkx^SN3aiTd(Vm9hp|2#YQcH;QKL1fl^OVg`^H6S zdc{kmW7yMSeoM%4A0VoHPX5Ea^Y~Gpso4*mnj8pFdjQq{XFk*;wS0~d7)kH~IUtU| z=_DT@0?M2lxrdXY5u|5ZSP))YL;cul7-SCS_2ao6CV;r#;c}56RNf8(m;oF$lQlbN zAiIFNlRgcpz4V=zi6F zr>v&hQPMdrj_}|Dl-d4EmHP@GkIY&o!_8-f9E zCSK>K%o+MIOo#1bAMsDV5&QQPn^>Go{ z1Y3$&k^Is;J0=~jwr%c76-0OU=zzf0k1`}{a*~7vxhEq5&mD#Q9 zo?5V{+n7-JLMcG2$Gomg!N``@Vs5V>?Ec1hNvOW*0Cl&F{6>?5jHiwd;iBOlRg0j* z#vl4#&bX`K`A{%pLWQf=^Yc=Ylw)S~BZcHwB#k?b8_VAoqS^VSDEB&o>$aQL^4agS z0wBqts3eiCYM2kuii&i2nu5-nw<`VHrCmzqyC|VYy$6hag=EtiZ>Ta#?E3&JsCb62 zCDuJqqhut-cc8FNLmEM5vPzzS4l7hou69%0>xozm82R~6S?7X(KnUTzv#Jz29y^$W z*j4DEut`}MV4E?>biVJF-XFVpLwLSHy5lo%j4eT)+~F0keetLC9)i#cSz0>@-v=K3 z3aq5BVW!CG-^@so1jB5B!jEGtk3k7Jjy9r%pe#SuwyR~tW3659z>{Cg*<&5UO2lg* z%HXWS8&{?L37iatc3sf-h*qVpOZOn)08WbNWjt0N^N}?Roxz6T$@hPl`rW$#6Ey@; z$edmM1^*uy0zS+ENMjFY&`4-i2B5-Zy&Cz)g(-wu@o)ubo}k!!csT=xmF9mAHmxH2 zOe!U)%KOZ_@2k0oAx;g%;dhAjnpFr)Y`-fI9?Cdx4{n~w%;?ve`TFApRm(oJo<`px zSFzoRIPu)2PL~d1t~l~Q?z|&er%*n2Su5k3G``AiCog&5QAAGQ{6Q~sYnHgGg3Ywt z14=I2@TrW_K44)5_YcQv0~H&&a>@5$vRf$`XScXhBObPs7eH>o_Jh04Tyu^!RQb|h z3=>7+g>M*H@rvp zy4Ow3uU8&vv7ZfaNUTq5MX!hbWx-97&|3BAYjBCJ%q!Md%<3VxwntpWqG~@4QsQDq zlrlFx$`(I%dWkd%5tjU3e-q4ybZ2fGkO}*3O@sB~{~s?0HPRvp&2hg3K$z8gex?aV zz;|3`ogtyfAQ$WMo{$LG;djeh0fPl&t^{6`1Z*}|$&yi*{SJVO(1GU}RrC#d`blyyO3R;?2W@{27V%PJml9B28>)k@=p?;x{6!wHX+;tom@#5DW2;HH|j=EefibLVZ1Ko zs3d=6ZzgIS$=TNfGM_Ga0&m`zFO85I7Y?UVcekL!1>FUgx2_QyL_0SN8_V;O*ph5h z;GUS<{}3PeEiIc7VNkbBmc@P_fPdhv?eTJ&G{;jcq(@}}l97@JP4w5`5hv@daqM!9VuGQ#;vt z2rM4epQ9gsbnLEaR5V?HtbX*b$>Kj^>zt9wPJ^>!c|EjR3s`Q!f0rA0f3FhONs!5Z zJqFfJmihr=G>1suwk7Jmx5I_8O5>MRWXD=xt%PU~>vTO5AS8qK6ER?ETcEDe+{b%lsJTz&|IMjYK6L#U zESmo)XAUXnFwGAaMJQuFnkkb+tUT1>B7#47PKYySCM5`>s~m05IW=#~s8`T?s~dA) zN-^_O^Y_*B@Rn^wo9tF_S#UlH!tN81MN1Smkis|N%DNO`0wjU|A@n0V;-}3(YwlCa z6#T4HoStq0I84&;Hj}-d6)C`2jF%kT(+m+?GpLe2J8};#2}Sq0u6u%)WlLEmcUPcd zvcjg=FF3Y>;h^lfJz!Aq8`IE61cAlJx?j4AFAOu6tA+0)<|%3fna&?B)udofPZJ)O ztR(dj&pcH?aPL!K<|&E=&&b2Y!aKm}`o$%;e60z61VtZmW9Xc6IZ_tZlj5CpC%-{$ zGb4=&4zEa?CvGQDJ_y5n37va`RAFV)39?^@fDmEcX;DQ@19SnGMUEaTz4$`kY?VQ#Gr@@6%=1jo zO&nv$iQ`pyv6a^aI$GnT(kD#tcf8GC(u|{?O9`6WxwUi)6xyECl+n%r5=$LT>Sn)_ z+vAY@QY1X02*yd={RRI%met-j@3;YT4i=XhgMo~{u_KorE$OYW94w4fQN4h$ z9O?Lz=zrkB9&d@;^<=7)!GCg+f3HdQaxc08eMh7S-zGfx6U+%52g@rj1@T=EFak-vIb3h5&s3$58YfFcfJYL=CM$In4FN6(#91bvmE_ z>Wl!ZeztuY#JvzCju|IiJ>|Z?)|gO@vkRqEg=+akLFhTda8&k`$+!q~ zF5e?iU(=PW%Ur@B%)5C<1wqW5mTBm9w5+d0=n?*uT z=%a|Dii4GQGE|Txpdmijv=$Ya`M8KGqd*CLJ#Zf2VTv0d4bPFg0T4c;<*Leb zIo=(=W~9lD%N`-T1(bt~@^9E;X9<%M_UDn*psGGu-Mo5x89;NOFBLjrz}i=`7c`G9dSUFz7IHIyn2K4 zO@Xxn_=ZRehY|_JEbmK(^oQ5Ue)Iq1Sf57GxzxhFfj)F=fImt;4(Ed)M?$&{f36EVEjXk;aW* zD#6_6qy@3&iHChRRT)=sFF2ctTeSyMomlL<+qxvdFpH*`_v0H8D|RnT&%(=ny0KcT zRglvei>+Wer150tT({_wk7uosj9MBKfk|Z^!vS*%W<=H_^`;Pa35MA>fL>!*@PkqC z(lw0$d6smYMyASDEZ?bzxp4<^Xv>o)+U6cZ>qO@_Fq~){xyA0qx2sD17zZG%+`M>rOVkfx7cUW zl>$Fpa~9A~NunHwxbHK3_4A0mkNkJtG0Z?8AO9JCalW-p@PG}9M z^~vAz1=8l!eGWTcq{l5~%AdObJ&W+)d~T_#%YFd#5Fc=-u;m)IUy25{E2r&~OeX;a z?szLp-3mZBidvxd!@4lesyo*kl2cs z4`Xqfy~fueOn3VDb1AAoTA3}7-M@V&XuujqIP%>`5%ZP)hNsB_tzEWIKdO~>UEJ>`~)q(z^l7`+Ojy3Xwza?r4$*7IYBk>J@fBm?S~K zWbnIcH{?QPz&%m$-Lr4#syWGsgNL;WsK^*x3MN3exjA6C#)iDn5cx8+)3GiL9c z1kSL@VK*A0c1Q{jPhNU7dX2zgJ2XnT!P%Ce&}hEeAlGTvb(}Q*P9~(e)gmon)P@~2 zrs>Q?lBvUH!wD4wKO-<0S8F3UCk`UmM^$4;vE+b#;9-6}Oik?2tH)wDqd9;mbO|`m z!l}DE(Eg&~@J@Cp-{yf3>FcxOMFMIN#pE>k$n5M{Bm>AzCgynyANP^Qc44@n&W*g#8}|;9&!j;Eg}yaJlawmyzXm0=X35y65) ziU=~4NRt2>5QknB2uM>(sG$f*5mb;CklqYRlV*@+Xi}sXsiC8w6bT(fia_qS6L9W4 z-nZ|Cmvc_od!4n`-e>Lg|5@6qb;I&Mi?&A^rFRZXnih8)3BtL!G7l)SAOp&B7Wj`~ z8{-TZ?$-T@E83aFZRYhD=0u95reOfbGAsBg% zE}Uq_OdJlVi&xlQ!P6(sor`58Iq+vR7{p8Ff7OAsh?lLgP{ot!~ zW9O36L;f*RS*LZt*$iz7XKJg~fuWGC*2FY3_xXn0Jrwu40xz;InU3!o z56{xYWetTbs4H_t3BRjJ1A+JJwaKtk9IskvUB{I|F2a~Pjby|ecx@1LdQO6?T#r*c z#l{jkzB=6jB3SFkkA&v(1?oB!KlQM*J(ewB@vHCf9qCM3gB-0o`bw3BTq!(3-=o{M z?yudVej`Vfw#&)-%XvLo$>lJ5*vpS`v(023E;m{-n_06XA_<=7ogYz1*_%+R)izzL z*yA2Cl;X4d)5(pqL8tCh>$0Z~I>5zdz{xtL?dwfr_;3{(SnNRLAqqmhPjG-vGHO(Z z5%kgm4I5DYj*1j8-b#S#%91WroPN%3yNcWoavz^-{#J9$t*58=$#(U`{_zt)Yp6GW zwax*Wc!_H_2P2Ay9Pi2J_!G`N?(o=@Bl~%=-T4frTshn#tAgH$?o17hX>98Fy{~wNd6Y`evH(kB)f|6L2)risszOx zhxKP?mEQkQ)T*|x?O3ov2C$UL&4B4WZX$X@kCk*R)8)egH5s1c!RCEj2DhwnwTSl#q#qqxxHB=|Q z)O_Gz(YDV3^4}&qoKn3pO~01JevahY$aX7M(+UnFZl|DfeFPngnxNxH-cxsk2eVVp zM&A0hShKqf-_Rzja{9+yiPKbafuf;AAb33hsU)j~U^gzOePqDCYl~~Q&Y)Js_M%?5 zElnd}836$s+J3=&m0kQlW>cKV#lUihCS}35#m=dut?!^GnW+Dr`t{WWMa7J@`6??P zJxa>Re)9f|j$E``Ufzgdzw3({B64WQ>Wh8JC(}_5aFRqVM7^J+4#1FOo-Oy&OWB9b z;b%r0P3QYoo=K0=))K&w4hwbm_3(<9Q|REDFQX!-vACc#dhMg_R(9KopEXvp8DZ@ z^zDr-rm{ae#UWK8T}1qy0e;b`8&{pE^9n67u%RljF(4094NAu={O;9T=gcenvux4y z0ML_R%3j7B2s&{bt`x`6OJ1gXBj;9rQJcEOp*Ef-P0HLbRo93qD5WW8QcdF_M`tw? zQYO9mKIiPAFQ8%!r+}@G6!-15+n!4`N6?`|v)&19wm*@)2To!^0VxGcw(1LBHMf=C68* zUUx*2r1!3}?i+Q7?TkY`gvFspt>xs^_1{Z;J*(@I5pgY<;wlHxriyFq!0h``G4Y>HxyVGk1{?7>L>Cy7a zkid1rcl8WhLSz3L7l;Inw32Q_l`{{QlP6$NIW}~}{sV&$U9*#t!SPPvEkzF3BGAJE@jEQu8I@WoBPC44b5PCw_?qd4VT&5ZXG+aA~id5?wV zwsWeR%W9ZN&0SqhDZp*ZC{iw;i8tOzMF!`Q#HSNNf7J87Tz)_1$RnkwmaxGa*stna zW*LZ{pxZn%01_gTlUIi^Zh~W8jRhNX6)W_NJ(UaiDl5>?ast(z83sdh+I@eyQJ9U35>R6>xd+t^ zw!}*F2}MhFO&@DAbk=F{q?7yt*0iA-=0)+mA%!l^g@TeUb|9fqbS7zulD@Ut^?K_n z2N_BHoYfv57TM=&HgSvJe__uCLCsmAF1k6e;K^6F@?Q=pAiVfeCpXyAOT4a@fASc2 zX+fHkCafA?x^_vo$>SX@2%fgPO9ai*#N(K4VI^?t9WuqD$Qc{DE7%G}S%3Gk*1Vp^ z#8ckHl>yYT;_7Jqq;r;=#!LMS8pA$MWBPO?>Tknz^3&2iJUDk7Tly6=pyfer z$M@_E=(ZAS_p;s+in}{F=XxJ6Z~9<66GdieH-sjSX=KDzJXAwCh=S)}I8rzkRzao}D zBYc#SCxZ%qeD_IbUY4Hc7?kHLH@EeUmT#exYZb(g7s>@JXl|JVE8KLtN1<)02H zhOvc5AgG<8R#+(@-O&>P_U_23?*v1@LfqlBIbNR?DD11c7Uz0K@3o>~d+uoXJd?B8 zi(%CE9@=IJ)u~mvHQ?2O`sF&nu1A7zxdjNfCgt77jytg@t8sg`@lQf4IvDmcjrMmr|5g0evi5^JauP!?%HG%}Z__ zg*QdlReb1!g81*)T&yRpF9ao`$_9NZmq!KMCkV!B6*YzNgDQ4c9qi?%URcB=P4wf) z!mG$;Soy|4-dGng z&l;|o@Zn8UnW*AnKqUutoYhoi(r6!eCNj>8yk)$8p*u;Oe@^XQM;oIg`$Tu~!@jA> znyh@5>LIw<=Zlqew)XWM;OP^o%|c#gOJv#s1nDLa;j32)%FzG>rGPqs8usCzw^#zw zBqSSpMfdK20?gssGhz6LRLKY}}51a85T~GW*Vy4YVNOBIQXs-pJuwQMDFxA=}eWbXDi0 zS~Z0DX4{R4>gNI{@A}GOZ38&V!4ia(`u6<8tog4E(yy(j5DgXK?`i&IfGVs8JEm@& zT#8fCbuI8_0tDw#!FuG{mPxUC;rU&K@N9UEz~bhZ)9Er2VR$L@3SO(@Yw!iUf0Ose z^z+IcSf@br2))U0KQj-co|j7)%OPCxA)YX2QaX8Q>#qlA2XFK#h>akvZt#?E7}GtR z&wY~@*$Ns5Y9EF%JNm%~XUP_l!h$ib>K>GOUcz7|DfLMvMyA7f1ImtyvP>BuIDdZ- zXe_n=)1SbMuQeS{Mk{Rafr19Ki52ugo-}e;5^Z>%QYc=! zbM4+d<~uVbSJ1|hZc=iEA#kcVce(4Fz^M`y0C1B}D+v)xNiX2lp$vx_GKzp7{;!V& zT#iKivN43M+W?v>_(3KLmG$~hqAtWm;6ekyvK9msOK@XS4(YCU^tM(TjwirnBAcn# lc7`73s6FK~f>iALH;9N^<}SFN1teOU+e#Wp{0+0f{{yQ9a;*RW literal 24671 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 Z;tnwK6mh2Y;2#GZQ&WQ9F1=+H@IR(MV37a- 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..a2cc8201f --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_colorbar_values.py @@ -0,0 +1,124 @@ +""" +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. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + +FIG_W, FIG_H = 400, 300 +STRIP_W, VALUE_GUTTER = 16, 39 # 3.6 × the 10 px tick size, plus a 3 px gap + + +def _colorbar_figure(lo=-1.5, hi=1.5, label=None, **extra): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.linspace(lo, hi, 32 * 32, dtype=np.float32) + .reshape(32, 32), cmap="gray") + 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 _strip_columns(ink): + """``(left, right)`` columns of the colorbar strip: the LAST run of ink on + the middle row that is exactly the strip's width.""" + row = np.where(ink[ink.shape[0] // 2])[0] + assert row.size, "nothing rendered" + 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: + return int(start), int(end) + raise AssertionError(f"no {STRIP_W} px strip on the middle row: " + f"{list(zip(starts, ends))}") + + +def _value_gutter(ink): + """The ink right of the strip, restricted to the strip's own rows.""" + left, right = _strip_columns(ink) + rows = np.where(ink[:, left:right + 1].any(axis=1))[0] + top, bottom = int(rows[0]), int(rows[-1]) + gutter = ink[top:bottom + 1, right + 1:right + 1 + VALUE_GUTTER] + return gutter, (top, bottom) + + +class TestGeometry: + 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). + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((8, 64), dtype=np.float32)) + before = plot.plot_box()["width"] + plot.set_colorbar_visible(True) + assert before - plot.plot_box()["width"] >= STRIP_W + VALUE_GUTTER + + def test_the_gutter_grows_with_the_tick_size(self): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((8, 64), dtype=np.float32)) + plot.set_colorbar_visible(True) + small = plot.plot_box()["width"] + plot.set_tick_label_size(14) + assert plot.plot_box()["width"] < small + + +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_stay_inside_the_strip_height(self, take_screenshot): + fig, _plot = _colorbar_figure() + ink = _ink(take_screenshot(fig)) + left, right = _strip_columns(ink) + strip_rows = np.where(ink[:, left:right + 1].any(axis=1))[0] + text_rows = np.where(ink[:, right + 1:right + 1 + VALUE_GUTTER].any(axis=1))[0] + assert text_rows[0] >= strip_rows[0] and text_rows[-1] <= strip_rows[-1] + + def test_a_tiny_range_keeps_the_two_numbers_apart(self, take_screenshot): + fig, _plot = _colorbar_figure(lo=0.999, hi=1.001) + gutter, _ = _value_gutter(_ink(take_screenshot(fig))) + rows = np.where(gutter.any(axis=1))[0] + gaps = np.where(np.diff(rows) > 1)[0] + assert gaps.size >= 1, "the two values were drawn on top of each other" + + 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/+colorbar-values.new_feature.rst b/upcoming_changes/+colorbar-values.new_feature.rst new file mode 100644 index 000000000..93001366b --- /dev/null +++ b/upcoming_changes/+colorbar-values.new_feature.rst @@ -0,0 +1 @@ +The 2-D colorbar now writes its display minimum and maximum beside the strip, in the same format as the axis ticks, so a labelled scale says how much and not only which way; the image gives up a fixed value gutter (mirrored in :meth:`plot_box`) so the numbers never clip. From 6585c683c9f7a19cbf9f503d2184c147337632ea Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 12 Sep 2026 05:54:44 -0500 Subject: [PATCH 2/3] chore: name the changelog fragment for PR 71 --- .../{+colorbar-values.new_feature.rst => 71.new_feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename upcoming_changes/{+colorbar-values.new_feature.rst => 71.new_feature.rst} (100%) diff --git a/upcoming_changes/+colorbar-values.new_feature.rst b/upcoming_changes/71.new_feature.rst similarity index 100% rename from upcoming_changes/+colorbar-values.new_feature.rst rename to upcoming_changes/71.new_feature.rst From 8c57aa8dd8476a51be7e25228d0df059f869aa79 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 12 Sep 2026 22:59:14 -0500 Subject: [PATCH 3/3] Colour the strip through the display window; one format for both ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the colorbar values (Copilot and a second pass): The strip painted the full colormap over the raw band while the image maps through the display window, so with a window narrower than the data the numbers sat beside colours the image never uses. The gradient now goes through the same rule as the pixels (_displayFrac, factored out of _buildLut32): saturated beyond the window, the way the image is. The strip also spans the image's letterboxed rect rather than the whole image area (_cbFitRect), so a wide image's numbers sit beside its pixels. Both ends are written in ONE format (fmtRange): 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 and not 1.3. "0.02" beside "-5.0e-3" is gone. The rotated label is placed right after the measured text instead of after the reserved gutter. Layout: the value gutter is budgeted for 7 characters (all of fmtRange's ordinary output) so a contrast drag never moves the image, grows only for longer strings, and is dropped in a cell too narrow to keep 40 px of image; _nativeGeom applies the same rule so a native export of a tiny image does not reserve a blank band. plot_box mirrors all of it, with a Python port of fmtRange (colorbar_texts) and JavaScript's half-up rounding, including toExponential's ties away from zero. Drawing: glyph extents come from measureText (+2 px), the two values are held apart on a tiny window, a strip too short for both shows the maximum alone at its own place (never pushed off the top), nothing on one too short for that, and a non-finite end draws nothing. Tests find the strip by a tall 16 px run rather than the middle row alone (a squat cell's strip may not reach it) and cover: the shared format, the window-coloured strip, the widened gutter moving the strip, the short strip, the narrow cell, and edge clipping (ink on the strip's edge row). --- AGENTS.md | 2 +- anyplotlib/FIGURE_ESM.md | 148 +++++------ anyplotlib/_base_plot.py | 98 +++++++- anyplotlib/figure_esm.js | 214 ++++++++++++---- anyplotlib/tests/baselines/imshow_labels.png | Bin 22192 -> 21981 bytes .../tests/test_plot2d/test_colorbar_values.py | 238 +++++++++++++++--- upcoming_changes/71.new_feature.rst | 2 +- 7 files changed, 538 insertions(+), 164 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebd32ba73..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,244 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 595310c19..1c8802e50 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -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**: `_cbTickW` / `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 303 / 311 / 323 / 333 / 343 | -| **Layout engine** `applyLayout` | 834 | -| `_buildCanvasStack` | 917 | -| `_createPanelDOM` | 1059 | -| `_createInsetDOM` / `_applyAllInsetStates` | 1200 / 1594 | -| `_resizePanelDOM` | 2307 | -| **2D drawing**: `_imgFitRect` | 2471 | -| `draw2d` | 2800 | -| `drawScaleBar2d` / `drawColorbar2d` | 2995 / 3275 | -| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3094 / 3117 / 3130 | -| `_drawAxes2d` (ticks, labels, title) | 3352 | -| `drawOverlay2d` / `drawMarkers2d` | 3505 / 3669 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2620 / 2644 / 2705 | -| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 790 / 821 | -| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4513 / 4595 | -| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4635 / 4650 / 4671 | -| **3D drawing**: `draw3d` | 5647 | -| Event emission `_emitEvent` | 6484 | -| 3D event handlers `_attachEvents3d` | 6541 | -| **1D drawing**: `draw1d` | 6765 | -| `_drawLine` (1D series + markers) | 6918 | -| `drawOverlay1d` / `drawMarkers1d` | 7211 / 7295 | -| Marker hit-test `_markerHitTest2d` | 7563 | +| **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` | 7820 | -| 2D events `_attachEvents2d` | 7862 | -| 1D events `_attachEvents1d` | 8255 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8530 / 8809 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8722 / 8736 / 8765 / 8800 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8934 / 9007 | -| Shared-axis propagation `_getShareGroups` | 9078 | -| Figure resize `_applyFigResizeDOM` | 9142 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9333 / 9396 / 9772 | -| Generic redraw `_redrawPanel` | 9962 | -| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10121 / 10317 / 10376 | -| Native-resolution render `_withNativeSize` | 10097 | -| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10410 / 10519 / 10698 | -| Export registry `registerExportAction` | 10575 | -| **Embedding API**: `createLocalModel` / `mount` | 11089 / 11145 | -| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11400 / 11787 | +| 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,8 +148,10 @@ with fallbacks to the historical defaults: `title_size||11`, | Function | Purpose | |----------|---------| -| `_cbTickW(st)` | Width of the value gutter right of the strip, where `display_min` / `display_max` are written: `round(3.6 * tick_size) + 3`. Fixed rather than measured so the layout is known before drawing and Python's `plot_box` can mirror it. | -| `_cbWidth(st)` | Width reserved for the colorbar: 0 when hidden, else `16 + _cbTickW + (label ? label_size+8 : 0)`. Subtracted from the image width in `_resizePanelDOM` / `_resizePanelCSS` so the strip, its values and its 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. | @@ -280,13 +282,13 @@ st.colorbar_label_size (label font sizes; optional) | Function | Line | Purpose | |----------|------|---------| -| **`_imgFitRect(iw,ih,cw,ch)`** | **2471** | Largest rect of aspect `iw:ih` centred in `cw×ch`; all 2-D coordinate functions derive from this | -| `draw2d(p)` | 2800 | Main render: `_resizePanelDOM` → decode → LUT → ImageBitmap → blit; then mask, axes, scale bar, colorbar, overlay, markers | -| `drawScaleBar2d(p)` | 2995 | Physical scale bar | -| `_rawBand(st)` / `_buildLut32(st)` | 2520 / 2527 | 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)` | 3275 | Gradient strip + min/max marks (band-relative, via `_rawBand`) + the two values written beside them (`fmtVal`, kept inside the strip height and held apart on a tiny range) + rotated label centred in the gutter right of the values | -| `_drawAxes2d(p)` | 3352 | Ticks (edge labels nudged inward both axes), axis labels + title via `_drawTex` | -| `drawOverlay2d(p)` / `drawMarkers2d(p)` | 3505 / 3669 | 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 @@ -639,13 +641,13 @@ exportCanvas(same opts) → {canvas, width, height} // synchronous, throws | Function | Line | Purpose | |----------|------|---------| -| `_cssScale` | 9997 | inverse of `_applyScale`'s `transform:scale()` | -| `_panelBox` | 10008 | the element whose rect bounds one panel | -| `_neutralizeView` / `_restoreView` | 10017 / 10042 | transient whole-extent view | -| `_nativeGeom` / `_nativeGuard` | 10057 / 10072 | native size + why-not message | -| `_withNativeSize` | 10097 | resize → redraw → run → restore | -| `_compositeCanvas` | 10121 | the compositor (`_drawEl` / `_drawPanel` …) | -| `exportCanvas` / `exportPNG` | 10317 / 10376 | 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 @@ -741,13 +743,13 @@ leaders that cross into the panel included. Pinned by | Function | Line | Purpose | |----------|------|---------| -| `_toast` | 10410 | transient bottom-centre message | -| `_copyCanvas` | 10445 | clipboard write + feature detection | -| `_showPngPreview` | 10469 | framed-document download fallback | -| `_downloadCanvas` | 10519 | `` or the preview | -| `registerExportAction` | 10575 | downstream extension point | -| `_menuRows` / `_openMenu` | 10629 / 10698 | menu model / DOM | -| `_panelAtPoint` | 10810 | 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 @@ -831,16 +833,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind. | Function | Line | Purpose | |----------|------|---------| -| `decodeBlocks` | 11400 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | -| `dense` | 11426 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | -| `ragged` | 11491 | the same three, over a row-pointer block (`offsets` + one array per column) | -| `maskFromWidget` | 11574 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | -| `rasterDisks` | 11614 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | -| `robustLevels` / `toU8` | 11643 / 11684 | the percentile window and the 8-bit code map, one implementation | -| `panelAxis` | 11762 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | -| `installTouchShim` / `reportEmbedHeight` | 11699 / 11718 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | -| `encodeBase64` / `typedArrayBytes` | 11738 / 11746 | a 3-D cloud's geometry channel is base64, not the binary side table | -| `mountNavigated` | 11787 | 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 a8195bc01..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. @@ -377,14 +465,18 @@ def plot_box(self) -> dict: height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0) # The colorbar strip, its value gutter, its label and its gap come out - # of the image width. Mirrors `_cbWidth` / `_cbTickW` in the JS. + # 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 = round(3.6 * (self._state.get("tick_size") or 10)) + 3 + 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)) + 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: diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 066a4f04d..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,24 +310,51 @@ function render({ model, el, onResize, onReadout }) { } // ── 2D gutter geometry helpers ─────────────────────────────────────────── + // 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; + } + + // 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. Fixed rather than measured so the layout is known - // before anything is drawn (and mirrored in Python's plot_box): fmtVal - // yields at most ~6 characters ("-0.012", "1.2e+4"), about 0.6 em each at - // the tick size, plus a 3 px gap off the strip. - function _cbTickW(st) { - return Math.round(3.6 * ((st && st.tick_size) || 10)) + 3; + // 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) { + function _cbWidth(st, availW) { if (!st || !st.show_colorbar || st.is_rgb) return 0; - const labelW = st.colorbar_label - ? Math.round((st.colorbar_label_size || 10) + 8) : 0; - return 16 + _cbTickW(st) + labelW; + return 16 + _cbTickW(st, availW) + _cbLabelW(st); } // Gap between the right edge of the image and the colorbar strip. Without a @@ -2332,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) { @@ -2350,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) { @@ -2438,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'; } @@ -2474,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) { @@ -2524,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; @@ -2538,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);} @@ -3280,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; - const half=tickPx*0.5+1; - const clampY=y=>Math.min(Math.max(y,half),imgH-half); - let yHi=clampY(_vToY(dMax)), yLo=clampY(_vToY(dMin)); - if(yLo-yHi{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 in the label gutter right of the - // values) + // Colorbar label (rotated −90°, centred right of the values) if(cbLabel){ ctx.save(); - ctx.translate(cbStripW + tickW + (cbW - cbStripW - tickW) / 2 + 1, imgH/2); + ctx.translate(labelCentre, imgH/2); ctx.rotate(-Math.PI/2); ctx.textBaseline='middle'; ctx.fillStyle=theme.unitText; @@ -9214,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; @@ -9247,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); @@ -10060,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 ed96603e84e5641c8d3663adbafa7aa3335a80ae..7a4c06c5f48c642a12a2558bf8ebc1f5e126f3b5 100644 GIT binary patch literal 21981 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 literal 22192 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 diff --git a/anyplotlib/tests/test_plot2d/test_colorbar_values.py b/anyplotlib/tests/test_plot2d/test_colorbar_values.py index a2cc8201f..4b0d6bfe8 100644 --- a/anyplotlib/tests/test_plot2d/test_colorbar_values.py +++ b/anyplotlib/tests/test_plot2d/test_colorbar_values.py @@ -6,21 +6,34 @@ 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, VALUE_GUTTER = 16, 39 # 3.6 × the 10 px tick size, plus a 3 px gap +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, **extra): - fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) +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="gray") + .reshape(32, 32), cmap="viridis") plot.set_clim(lo, hi) plot.set_colorbar_visible(True) if label: @@ -37,48 +50,137 @@ def _ink(img): 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: the LAST run of ink on - the middle row that is exactly the strip's width.""" - row = np.where(ink[ink.shape[0] // 2])[0] - assert row.size, "nothing rendered" - 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: - return int(start), int(end) - raise AssertionError(f"no {STRIP_W} px strip on the middle row: " - f"{list(zip(starts, ends))}") - - -def _value_gutter(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) - rows = np.where(ink[:, left:right + 1].any(axis=1))[0] - top, bottom = int(rows[0]), int(rows[-1]) - gutter = ink[top:bottom + 1, right + 1:right + 1 + VALUE_GUTTER] - return gutter, (top, bottom) + 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). - fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) - plot = ax.imshow(np.zeros((8, 64), dtype=np.float32)) + plot = self._wide_image() before = plot.plot_box()["width"] plot.set_colorbar_visible(True) - assert before - plot.plot_box()["width"] >= STRIP_W + VALUE_GUTTER + assert before - plot.plot_box()["width"] == STRIP_W + VALUE_GUTTER + 6 def test_the_gutter_grows_with_the_tick_size(self): - fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) - plot = ax.imshow(np.zeros((8, 64), dtype=np.float32)) + 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): @@ -92,21 +194,93 @@ def test_the_range_is_written_at_the_ends_of_the_strip(self, take_screenshot): # 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_stay_inside_the_strip_height(self, take_screenshot): + 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) - strip_rows = np.where(ink[:, left:right + 1].any(axis=1))[0] + 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] >= strip_rows[0] and text_rows[-1] <= strip_rows[-1] + assert text_rows[0] > top and text_rows[-1] < bottom def test_a_tiny_range_keeps_the_two_numbers_apart(self, take_screenshot): - fig, _plot = _colorbar_figure(lo=0.999, hi=1.001) + # 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)) diff --git a/upcoming_changes/71.new_feature.rst b/upcoming_changes/71.new_feature.rst index 93001366b..94ace942e 100644 --- a/upcoming_changes/71.new_feature.rst +++ b/upcoming_changes/71.new_feature.rst @@ -1 +1 @@ -The 2-D colorbar now writes its display minimum and maximum beside the strip, in the same format as the axis ticks, so a labelled scale says how much and not only which way; the image gives up a fixed value gutter (mirrored in :meth:`plot_box`) so the numbers never clip. +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.