Skip to content

Write the display minimum and maximum beside the 2-D colorbar - #71

Open
CSSFrancis wants to merge 3 commits into
mainfrom
feat/colorbar-ticks
Open

Write the display minimum and maximum beside the 2-D colorbar#71
CSSFrancis wants to merge 3 commits into
mainfrom
feat/colorbar-ticks

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

The 2-D colorbar drew a gradient, two white marks at display_min / display_max and a rotated label, and nothing else. A map labelled "strain (%)" therefore 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.

What changes

  • drawColorbar2d 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.
  • FIGURE_ESM.md anchors reconciled against the current file; AGENTS.md line count updated.

Tests

  • tests/test_plot2d/test_colorbar_values.py: geometry (gutter comes out of the image, grows with tick size) and rendering (values at the ends of the strip, inside its height, kept apart on a tiny range, label right of the values, RGB still draws no strip).
  • 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.
  • test_plot2d, test_labels, test_layouts, test_embed, test_interactive: 1534 passed.

Before / after (SpyDE strain window, same figure): the strip used to carry only "εxx (%)"; it now reads 1 at the top and -1 at the bottom beside it.

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.
@codecov-commenter

codecov-commenter commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.12%. Comparing base (03aff40) to head (8c57aa8).

Files with missing lines Patch % Lines
anyplotlib/_base_plot.py 94.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #71      +/-   ##
==========================================
+ Coverage   91.10%   91.12%   +0.02%     
==========================================
  Files          41       41              
  Lines        4799     4847      +48     
==========================================
+ Hits         4372     4417      +45     
- Misses        427      430       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Moderate issues remain in gutter sizing, rounding consistency, and short-strip text layout.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds formatted display minimum/maximum labels to 2-D colorbars and reserves matching layout space.

Changes:

  • Renders endpoint values and adjusts colorbar label placement.
  • Mirrors gutter geometry between Python and JavaScript.
  • Adds regression tests and updates documentation, changelog, and metadata.
File summaries
File Summary
upcoming_changes/71.new_feature.rst Added changelog entry
anyplotlib/tests/test_plot2d/test_colorbar_values.py Added colorbar geometry and rendering tests
anyplotlib/FIGURE_ESM.md Updated renderer documentation anchors
anyplotlib/figure_esm.js Added colorbar values and gutter layout
anyplotlib/_base_plot.py Mirrored colorbar geometry calculations
AGENTS.md Updated renderer line count
Review details

Suppressed comments (5)

anyplotlib/_base_plot.py:385

  • This uses Python's ties-to-even round, while the mirrored JavaScript helper uses Math.round (half-up). A fractional tick size that puts 3.6 * tick_size exactly on an x.5 boundary therefore makes plot_box() reserve a different width from the renderer; on a width-limited image, coordinate conversion and the colorbar position can be off by one pixel. Use a JavaScript-equivalent half-up calculation here.
            tick_w = round(3.6 * (self._state.get("tick_size") or 10)) + 3

anyplotlib/figure_esm.js:3328

  • This edge case makes the clamp interval invalid for short strips: when imgH < 2 * half (for example a native export of a 1-pixel-tall image), imgH - half is below half, so the Math.max/Math.min adjustments produce coordinates outside the canvas and the values are clipped. Handle strips that are too short by skipping the labels or reducing their layout/font before drawing.
      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<tickPx+2){
        const mid=(yLo+yHi)/2;
        yHi=Math.max(half,mid-(tickPx+2)/2);
        yLo=Math.min(imgH-half,mid+(tickPx+2)/2);

anyplotlib/figure_esm.js:3324

  • The half clamp does not account for the actual font ascent. With the default 10 px font, a maximum at the top of the strip is clamped to baseline y=6, but sans-serif glyphs can extend more than 6 px above that baseline, so the top value is clipped; the bottom bound is similarly only approximate. Clamp using TextMetrics.actualBoundingBoxAscent/actualBoundingBoxDescent (with a fallback) after setting the font.
      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));

anyplotlib/figure_esm.js:315

  • This new fixed gutter can exceed the panel's minimum width and overlap adjacent panels. Figure layout clamps grid cells to 64 px, but a labelled default colorbar needs 16 + 39 + (10 + 8) = 73 px before the 6 px gap; _resizePanelDOM then clamps imgW to 1 rather than shrinking the colorbar, so a 64 px cell places the colorbar beyond its panel. Make the colorbar responsive or coordinate the panel minimum with the reserved width.
  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 + _cbTickW(st) + labelW;

anyplotlib/tests/test_plot2d/test_colorbar_values.py:101

  • This assertion cannot detect edge clipping: the gradient paints every row of the strip, so strip_rows necessarily starts at 0 and ends at imgH - 1; any surviving text pixels in the gutter will satisfy both comparisons even if the glyphs are cut at either canvas edge. Assert that the text rows are strictly inside the canvas (or inspect font metrics) to make this regression test meaningful.
        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]
  • Files reviewed: 6/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread anyplotlib/figure_esm.js Outdated
Comment thread anyplotlib/tests/test_plot2d/test_colorbar_values.py Outdated
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).
@CSSFrancis

Copy link
Copy Markdown
Owner Author

Second round, from the Copilot review plus a pass by a reviewer reading it as someone who makes strain and DPC figures:

  • The strip is now coloured through the display window. It painted the full colormap over the raw band while the image maps through the window, so with a window narrower than the data the numbers sat beside colours the image never uses. The gradient goes through the same rule as the pixels now (_displayFrac, factored out of _buildLut32), saturated beyond the window the way the image is.
  • The strip spans the image, not the image area (_cbFitRect): a wide, letterboxed image no longer gets numbers floating far above and below its pixels.
  • One format for both ends (fmtRange): "0.02" beside "-5.0e-3" is gone. 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). The rotated label sits right after the measured text.
  • Layout: the value gutter is budgeted for 7 characters so a contrast drag never moves the image, grows for longer strings, and is dropped in a cell too narrow to keep 40 px of image; _nativeGeom applies the same rule. plot_box mirrors all of it, including JavaScript's half-up rounding and toExponential's ties away from zero.
  • Drawing: glyph extents from measureText, the maximum alone (at its own place, never pushed off the top) on a strip too short for both, nothing on one too short for that, nothing for a non-finite end.
  • Tests cover each of those; the one visual baseline with a colorbar is regenerated.

Follow-ups, deliberately not in this PR: nice intermediate ticks (findNice exists), a zero mark on a symmetric diverging range, and extend triangles when the data exceeds the window. Min/max at the ends is the first step.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Formatting, non-finite rendering, label placement, long-value handling, and a stale verification count remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

anyplotlib/_base_plot.py:41

  • The long-value case added by this PR cannot reach the formatter: quantizing Decimal(1.2e308) with the default decimal context (precision 28) raises decimal.InvalidOperation because the result needs hundreds of significant digits. colorbar_texts(-1.2e308, 1.2e308) therefore fails instead of returning the expected exponent strings and the mirrored layout breaks; use a local context whose precision is derived from the input coefficient before quantizing.
    mantissa = number.scaleb(-exponent).quantize(quantum, rounding=decimal.ROUND_HALF_UP)

anyplotlib/figure_esm.js:3371

  • The new gradient loop does not handle non-finite display ranges. set_display_window accepts any float, and an all-NaN input can also leave display_min/display_max non-finite; then _displayFrac returns NaN, ci is NaN, and st.colormap_data[ci] is undefined, so destructuring throws and aborts redraw even though fmtRange explicitly treats non-finite endpoints as no text. Guard the gradient for non-finite window/band values or choose a neutral fallback before indexing the LUT.
        const t=_displayFrac(st,hMin+frac*vRange);
        const ci=Math.max(0,Math.min(255,Math.round(t*255)));
        const [r2,g2,b2]=st.colormap_data[ci];

anyplotlib/figure_esm.js:3434

  • Math.min moves the label back inside the reserved value gutter. With the default 45 px gutter and 1.5 values, the measured text is much narrower, so the rotated strain (%) label remains before right + VALUE_GUTTER; the new test_the_label_sits_right_of_the_numbers assertion therefore cannot pass as written. Keep the label after the reserved gutter (for example, use Math.max here), or change the test to compare against the measured value extent.
        labelCentre=Math.min(labelCentre,cbStripW+3+textW+4+labelW/2);
  • Files reviewed: 6/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread anyplotlib/figure_esm.js
Comment on lines +90 to +97
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))];
Comment thread AGENTS.md

and reconcile against the two numbered tables (the section map near the top and
the 2-D function table). Both were last verified at 12,211 lines.
the 2-D function table). Both were last verified at 12,349 lines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants