diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 947457100730..c9eadb4663a4 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2479,8 +2479,7 @@ def _point_in_data_domain(self, x, y): (e.g. negative coordinates with a log scale). """ for val, axis in zip([x, y], self._axis_map.values()): - vmin, vmax = axis.limit_range_for_scale(val, val) - if vmin != val or vmax != val: + if not axis._scale.val_in_range(val): return False return True diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 480ce59e34ec..0793bb31e566 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -30,6 +30,7 @@ """ # noqa: E501 import inspect +import math import textwrap from functools import wraps @@ -114,6 +115,22 @@ def limit_range_for_scale(self, vmin, vmax, minpos): """ return vmin, vmax + def val_in_range(self, val): + """ + Return whether the value(s) are within the valid range for this scale. + + This method is a generic implementation. Subclasses may implement more + efficient solutions for their domain. + """ + try: + if not math.isfinite(val): + return False + else: + vmin, vmax = self.limit_range_for_scale(val, val, minpos=1e-300) + return vmin == val and vmax == val + except (TypeError, ValueError): + return False + def _make_axis_parameter_optional(init_func): """ @@ -196,6 +213,14 @@ def get_transform(self): """ return IdentityTransform() + def val_in_range(self, val): + """ + Return whether the value is within the valid range for this scale. + + This is True for all values, except +-inf and NaN. + """ + return math.isfinite(val) + class FuncTransform(Transform): """ @@ -400,6 +425,14 @@ def limit_range_for_scale(self, vmin, vmax, minpos): return (minpos if vmin <= 0 else vmin, minpos if vmax <= 0 else vmax) + def val_in_range(self, val): + """ + Return whether the value is within the valid range for this scale. + + This is True for value(s) > 0 except +inf and NaN. + """ + return math.isfinite(val) and val > 0 + class FuncScaleLog(LogScale): """ @@ -581,6 +614,14 @@ def get_transform(self): """Return the `.SymmetricalLogTransform` associated with this scale.""" return self._transform + def val_in_range(self, val): + """ + Return whether the value is within the valid range for this scale. + + This is True for all values, except +-inf and NaN. + """ + return math.isfinite(val) + class AsinhTransform(Transform): """Inverse hyperbolic-sine transformation used by `.AsinhScale`""" @@ -707,6 +748,14 @@ def set_default_locators_and_formatters(self, axis): else: axis.set_major_formatter('{x:.3g}') + def val_in_range(self, val): + """ + Return whether the value is within the valid range for this scale. + + This is True for all values, except +-inf and NaN. + """ + return math.isfinite(val) + class LogitTransform(Transform): input_dims = output_dims = 1 @@ -820,6 +869,14 @@ def limit_range_for_scale(self, vmin, vmax, minpos): return (minpos if vmin <= 0 else vmin, 1 - minpos if vmax >= 1 else vmax) + def val_in_range(self, val): + """ + Return whether the value is within the valid range for this scale. + + This is True for value(s) which are between 0 and 1 (excluded). + """ + return 0 < val < 1 + _scale_mapping = { 'linear': LinearScale, diff --git a/lib/matplotlib/scale.pyi b/lib/matplotlib/scale.pyi index 7cef8a3438a8..866509ee020d 100644 --- a/lib/matplotlib/scale.pyi +++ b/lib/matplotlib/scale.pyi @@ -12,6 +12,7 @@ class ScaleBase: def limit_range_for_scale( self, vmin: float, vmax: float, minpos: float ) -> tuple[float, float]: ... + def val_in_range(self, val: float) -> bool: ... class LinearScale(ScaleBase): name: str diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index f98e083d84a0..9f882103967e 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -371,3 +371,65 @@ def set_default_locators_and_formatters(self, axis): # cleanup - there's no public unregister_scale() del mscale._scale_mapping["custom"] del mscale._scale_has_axis_parameter["custom"] + + +def test_val_in_range(): + + test_cases = [ + # LinearScale: Always True (even for Inf/NaN) + ('linear', 10.0, True), + ('linear', -10.0, True), + ('linear', 0.0, True), + ('linear', np.inf, False), + ('linear', np.nan, False), + + # LogScale: Only positive values (> 0) + ('log', 1.0, True), + ('log', 1e-300, True), + ('log', 0.0, False), + ('log', -1.0, False), + ('log', np.inf, False), + ('log', np.nan, False), + + # LogitScale: Strictly between 0 and 1 + ('logit', 0.5, True), + ('logit', 0.0, False), + ('logit', 1.0, False), + ('logit', -0.1, False), + ('logit', 1.1, False), + ('logit', np.inf, False), + ('logit', np.nan, False), + + # SymmetricalLogScale: Valid for all real numbers + # Uses ScaleBase fallback. NaN returns False since NaN != NaN + ('symlog', 10.0, True), + ('symlog', -10.0, True), + ('symlog', 0.0, True), + ('symlog', np.inf, False), + ('symlog', np.nan, False), + ] + + for name, val, expected in test_cases: + scale_cls = mscale._scale_mapping[name] + s = scale_cls(axis=None) + + result = s.val_in_range(val) + assert result is expected, ( + f"Failed {name}.val_in_range({val})." + f"Expected {expected}, got {result}" + ) + + +def test_val_in_range_base_fallback(): + # Directly test the ScaleBase fallback for custom scales. + # ScaleBase.limit_range_for_scale returns values unchanged by default + s = mscale.ScaleBase(axis=None) + + # Normal values should be True + assert s.val_in_range(1.0) is True + assert s.val_in_range(-5.5) is True + + # NaN and Inf returns False since they cannot be drawn in a plot + assert s.val_in_range(np.nan) is False + assert s.val_in_range(np.inf) is False + assert s.val_in_range(-np.inf) is False