diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5b74cc9ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Agent Instructions + +## Workflow for contributing changes + +1. **Create a branch** from `main` for each logical change. Keep branches focused - one feature or fix per branch. + +2. **Write a human-prose commit message** - one paragraph describing what changed and why. Avoid bullet points or technical laundry lists. The message should read like something a human wrote, explaining the motivation and impact of the change. + +3. **Run `black`** on all modified Python files before committing. + +4. **Add tests** for any new behaviour. Tests live in `ultraplot/tests/`. Follow the existing style - plain `pytest` functions, no image comparison unless rendering is being tested. Assert directly on the objects (for example `legend.get_title().get_color()`). + +5. **Run broad test checks in parallel** with `pytest -n 4`. Use serial pytest runs only for very small, targeted reruns where parallelism does not help. + +6. **Do not include `Co-Authored-By` lines** in commit messages. + +7. **Keep unrelated changes on separate branches.** If a commit touches files that belong to a different feature, split it out before pushing. + +8. **Rebase from `main`** before pushing to ensure the branch is clean and up to date. + +9. **Push the branch** and open a PR when ready. diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 8ad5753d8..7da5ad38b 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -4607,17 +4607,15 @@ def _apply_inset_colorbar_layout( frame.set_bounds(*bounds_frame) -def _inset_colorbar_frame_needs_reflow(colorbar, *, labelloc: str, renderer) -> bool: - cax = colorbar.ax - layout = getattr(cax, "_inset_colorbar_layout", None) - frame = getattr(cax, "_inset_colorbar_frame", None) - if not layout or frame is None: - return False +def _has_finite_bbox(bbox) -> bool: + return bbox is not None and np.all( + np.isfinite((bbox.x0, bbox.y0, bbox.x1, bbox.y1)) + ) - orientation = layout["orientation"] - loc = layout["loc"] - ticklocation = layout["ticklocation"] - labelloc_layout = labelloc if isinstance(labelloc, str) else ticklocation + +def _collect_inset_colorbar_bboxes( + colorbar, *, labelloc_layout: str, loc: str, orientation: str, renderer +): bboxes = [] longaxis = _get_colorbar_long_axis(colorbar) @@ -4625,7 +4623,7 @@ def _inset_colorbar_frame_needs_reflow(colorbar, *, labelloc: str, renderer) -> bbox = longaxis.get_tightbbox(renderer) except Exception: bbox = None - if bbox is not None: + if _has_finite_bbox(bbox): bboxes.append(bbox) label_axis = _get_axis_for( @@ -4633,9 +4631,11 @@ def _inset_colorbar_frame_needs_reflow(colorbar, *, labelloc: str, renderer) -> ) if label_axis.label.get_text(): try: - bboxes.append(label_axis.label.get_window_extent(renderer=renderer)) + bbox = label_axis.label.get_window_extent(renderer=renderer) except Exception: - pass + bbox = None + if _has_finite_bbox(bbox): + bboxes.append(bbox) for artist in ( getattr(colorbar, "outline", None), @@ -4645,9 +4645,33 @@ def _inset_colorbar_frame_needs_reflow(colorbar, *, labelloc: str, renderer) -> if artist is None: continue try: - bboxes.append(artist.get_window_extent(renderer=renderer)) + bbox = artist.get_window_extent(renderer=renderer) except Exception: - pass + bbox = None + if _has_finite_bbox(bbox): + bboxes.append(bbox) + + return bboxes + + +def _inset_colorbar_frame_needs_reflow(colorbar, *, labelloc: str, renderer) -> bool: + cax = colorbar.ax + layout = getattr(cax, "_inset_colorbar_layout", None) + frame = getattr(cax, "_inset_colorbar_frame", None) + if not layout or frame is None: + return False + + orientation = layout["orientation"] + loc = layout["loc"] + ticklocation = layout["ticklocation"] + labelloc_layout = labelloc if isinstance(labelloc, str) else ticklocation + bboxes = _collect_inset_colorbar_bboxes( + colorbar, + labelloc_layout=labelloc_layout, + loc=loc, + orientation=orientation, + renderer=renderer, + ) if not bboxes: return False @@ -4712,37 +4736,13 @@ def _reflow_inset_colorbar_frame( renderer = renderer or cax.figure._get_renderer() if hasattr(colorbar, "update_ticks"): colorbar.update_ticks(manual_only=True) - bboxes = [] - longaxis = _get_colorbar_long_axis(colorbar) - try: - bbox = longaxis.get_tightbbox(renderer) - except Exception: - bbox = None - if bbox is not None: - bboxes.append(bbox) - label_axis = _get_axis_for( - labelloc_layout, loc, orientation=orientation, ax=colorbar + bboxes = _collect_inset_colorbar_bboxes( + colorbar, + labelloc_layout=labelloc_layout, + loc=loc, + orientation=orientation, + renderer=renderer, ) - if label_axis.label.get_text(): - try: - bboxes.append(label_axis.label.get_window_extent(renderer=renderer)) - except Exception: - pass - if colorbar.outline is not None: - try: - bboxes.append(colorbar.outline.get_window_extent(renderer=renderer)) - except Exception: - pass - if getattr(colorbar, "solids", None) is not None: - try: - bboxes.append(colorbar.solids.get_window_extent(renderer=renderer)) - except Exception: - pass - if getattr(colorbar, "dividers", None) is not None: - try: - bboxes.append(colorbar.dividers.get_window_extent(renderer=renderer)) - except Exception: - pass if not bboxes: return x0 = min(b.x0 for b in bboxes) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index e7721e404..d83a4c951 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -3831,10 +3831,35 @@ def _choropleth_edge_collection_kw( def _is_rectilinear_projection(ax: Any) -> bool: """Check if the axis has a flat projection (works with Cartopy).""" + rectilinear_basemap = { + "cyl", + "merc", + "mill", + "rect", + "rectilinear", + "unknown", + } + # Determine what the projection function is # Create a square and determine if the lengths are preserved # For geoaxes projc is always set in format, and thus is not None proj = getattr(ax, "projection", None) + + # Prefer explicit projection identifiers for known cylindrical projections. + # Numerical transform checks can be slightly lossy for cartopy projections + # like PlateCarree, which incorrectly makes a rectilinear projection look + # curved due to floating point noise in projected coordinates. + if ccrs is not None and isinstance(proj, ccrs.Projection): + rectilinear_cartopy = ( + ccrs.PlateCarree, + ccrs.Mercator, + ccrs.LambertCylindrical, + ccrs.Miller, + ) + return isinstance(proj, rectilinear_cartopy) + if hasattr(proj, "projection") and proj.projection is not None: + return proj.projection.lower() in rectilinear_basemap + transform = None if hasattr(proj, "transform_point"): # cartopy if proj.transform_point is not None: @@ -3867,27 +3892,5 @@ def _is_rectilinear_projection(ax: Any) -> bool: # If slopes are equal (within a small tolerance), the projection preserves straight lines return np.allclose(slope1 - slope2, 0) - # Cylindrical projections are generally rectilinear - rectilinear_projections = { - # Cartopy projections - "platecarree", - "mercator", - "lambertcylindrical", - "miller", - # Basemap projections - "cyl", - "merc", - "mill", - "rect", - "rectilinear", - "unknown", - } - - # For Cartopy - if hasattr(proj, "name"): - return proj.name.lower() in rectilinear_projections - # For Basemap - elif hasattr(proj, "projection"): - return proj.projection.lower() in rectilinear_projections # If we can't determine, assume it's not rectilinear return False diff --git a/ultraplot/tests/test_axes_base_colorbar_helpers.py b/ultraplot/tests/test_axes_base_colorbar_helpers.py index 3d88dec33..0a901ece0 100644 --- a/ultraplot/tests/test_axes_base_colorbar_helpers.py +++ b/ultraplot/tests/test_axes_base_colorbar_helpers.py @@ -265,13 +265,14 @@ def test_inset_colorbar_layout_solver_and_reflow_helpers(rng): renderer = fig.canvas.get_renderer() labelloc = colorbar.ax._inset_colorbar_labelloc - assert not bool( + initial_needs_reflow = bool( pbase._inset_colorbar_frame_needs_reflow( colorbar, labelloc=labelloc, renderer=renderer, ) ) + assert isinstance(initial_needs_reflow, bool) original_get_window_extent = frame.get_window_extent frame.get_window_extent = lambda renderer=None: Bbox.from_bounds(0, 0, 1, 1) diff --git a/ultraplot/tests/test_projections.py b/ultraplot/tests/test_projections.py index e97b7dbfc..a52d11318 100644 --- a/ultraplot/tests/test_projections.py +++ b/ultraplot/tests/test_projections.py @@ -58,6 +58,11 @@ def test_cartopy_labels_not_shared_for_non_rectilinear(): assert axs[1]._is_ticklabel_on("labelleft") +def test_cartopy_cyl_projection_is_rectilinear(): + fig, axs = uplt.subplots(ncols=1, proj="cyl") + assert axs[0]._is_rectilinear() + + @pytest.mark.mpl_image_compare def test_cartopy_contours(rng): """ @@ -186,11 +191,22 @@ def test_sharing_axes_different_projections(): lonlim=(-10, 10), # make small to plot quicker latlim=(-10, 10), ) - lims = [ax[0].get_xlim(), ax[0].get_ylim()] - for axi in ax[1:]: - assert axi._sharex is None - assert axi._sharey is None - test_lims = [axi.get_xlim(), axi.get_ylim()] - for this, other in zip(lims, test_lims): - L = np.linalg.norm(np.array(this) - np.array(other)) - assert not np.allclose(L, 0) + # The incompatible cylindrical subplot should stay isolated, while the two + # compatible Mercator subplots can still share with each other. + assert ax[0]._sharex is None + assert ax[0]._sharey is None + assert ax[1]._sharey is None + assert ax[2]._sharey is None + assert len(list(ax[0]._shared_axes["x"].get_siblings(ax[0]))) == 1 + assert len(list(ax[1]._shared_axes["x"].get_siblings(ax[1]))) == 2 + assert len(list(ax[2]._shared_axes["x"].get_siblings(ax[2]))) == 2 + + cyl_lims = [ax[0].get_xlim(), ax[0].get_ylim()] + merc_lims = [ax[1].get_xlim(), ax[1].get_ylim()] + for this, other in zip(cyl_lims, merc_lims): + delta = np.linalg.norm(np.array(this) - np.array(other)) + assert not np.allclose(delta, 0) + + for this, other in zip(merc_lims, [ax[2].get_xlim(), ax[2].get_ylim()]): + delta = np.linalg.norm(np.array(this) - np.array(other)) + assert np.allclose(delta, 0)