Write the display minimum and maximum beside the 2-D colorbar - #71
Write the display minimum and maximum beside the 2-D colorbar#71CSSFrancis wants to merge 3 commits into
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟡 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 usesMath.round(half-up). A fractional tick size that puts3.6 * tick_sizeexactly on an x.5 boundary therefore makesplot_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 - halfis belowhalf, so theMath.max/Math.minadjustments 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
halfclamp 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 usingTextMetrics.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) = 73px before the 6 px gap;_resizePanelDOMthen clampsimgWto 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_rowsnecessarily starts at 0 and ends atimgH - 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.
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).
|
Second round, from the Copilot review plus a pass by a reviewer reading it as someone who makes strain and DPC figures:
Follow-ups, deliberately not in this PR: nice intermediate ticks ( |
There was a problem hiding this comment.
🟡 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) raisesdecimal.InvalidOperationbecause 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_windowaccepts any float, and an all-NaN input can also leavedisplay_min/display_maxnon-finite; then_displayFracreturnsNaN,ciisNaN, andst.colormap_data[ci]isundefined, so destructuring throws and aborts redraw even thoughfmtRangeexplicitly 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.minmoves the label back inside the reserved value gutter. With the default 45 px gutter and1.5values, the measured text is much narrower, so the rotatedstrain (%)label remains beforeright + VALUE_GUTTER; the newtest_the_label_sits_right_of_the_numbersassertion therefore cannot pass as written. Keep the label after the reserved gutter (for example, useMath.maxhere), 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
| 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))]; |
|
|
||
| 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. |
The 2-D colorbar drew a gradient, two white marks at
display_min/display_maxand 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
drawColorbar2dwrites the two values beside their marks, in the samefmtValformat the axis ticks use, kept inside the strip's height and held apart when a tiny display range would stack them.round(3.6 * tick_size) + 3px (_cbTickW) rather than a measured text width._cbWidthreserves it andplot_boxmirrors it, so Python-side geometry stays exact. The rotated label moves right of the values.FIGURE_ESM.mdanchors reconciled against the current file;AGENTS.mdline 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).imshow_labels) is regenerated: the image is narrower by the gutter and the strip now reads1.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
1at the top and-1at the bottom beside it.