diff --git a/doc/ref/plotting_options/axis.ipynb b/doc/ref/plotting_options/axis.ipynb index 9bda27c74..06cda67b9 100644 --- a/doc/ref/plotting_options/axis.ipynb +++ b/doc/ref/plotting_options/axis.ipynb @@ -881,7 +881,9 @@ "(option-xlim_ylim)=\n", "## `xlim / ylim`\n", "\n", - "The x- and y-axis ranges can be defined with the `xlim` and `ylim` options, respectively. These options accept a 2-tuple representing the minimum and maximum bounds of the plotted ranged. One bound can be left unset by using `None` (e.g. `xlim=(10, None)` means there is no upper bound)." + "The x- and y-axis ranges can be defined with the `xlim` and `ylim` options, respectively. These options accept a 2-tuple representing the minimum and maximum bounds of the plotted ranged. One bound can be left unset by using `None` (e.g. `xlim=(10, None)` means there is no upper bound).\n", + "\n", + "If [`tiles`](option-tiles) is provided and the `xlim` and `ylim` are in `lat`/`lon` coordinates, they will be automatically transformed to Web Mercator coordinates." ] }, { diff --git a/doc/ref/plotting_options/geographic.ipynb b/doc/ref/plotting_options/geographic.ipynb index ff44e0069..8451ede6a 100644 --- a/doc/ref/plotting_options/geographic.ipynb +++ b/doc/ref/plotting_options/geographic.ipynb @@ -425,6 +425,37 @@ "layout.cols(2)" ] }, + { + "cell_type": "markdown", + "id": "dcc48e78", + "metadata": {}, + "source": [ + "If `xlim` and `ylim` are in `lat`/`lon` coordinates, they will be automatically transformed to Web Mercator coordinates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f86e368", + "metadata": {}, + "outputs": [], + "source": [ + "import holoviews as hv\n", + "import hvplot.pandas # noqa\n", + "import xyzservices.providers as xyz\n", + "\n", + "df = hvplot.sampledata.earthquakes(\"pandas\")\n", + "\n", + "plot_opts = dict(x='lon', y='lat', alpha=0.2, c='brown', frame_width=250, xlim=(-130, -70), ylim=(30, 60))\n", + "layout = (\n", + " df.hvplot.points(tiles=True, title=\"Default: OpenStreetMap\", **plot_opts) +\n", + " df.hvplot.points(tiles=xyz.Esri.WorldPhysical, title=\"xyz.Esri.WorldPhysical\", **plot_opts) +\n", + " df.hvplot.points(tiles='EsriTerrain', title=\"EsriTerrain string\", **plot_opts) +\n", + " df.hvplot.points(tiles=hv.element.tiles.EsriImagery, title=\"HoloViews Tiles\", **plot_opts)\n", + ")\n", + "layout.cols(2)" + ] + }, { "cell_type": "markdown", "id": "d0db0527-bb56-4e99-8864-22819b6367c3", diff --git a/hvplot/converter.py b/hvplot/converter.py index 361b55da5..49a51f468 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -47,7 +47,7 @@ from holoviews.plotting.util import process_cmap from holoviews.operation import histogram, apply_when from holoviews.streams import Buffer, Pipe -from holoviews.util.transform import dim, lon_lat_to_easting_northing +from holoviews.util.transform import dim from pandas import DatetimeIndex, MultiIndex from .backend_transforms import _transfer_opts_cur_backend @@ -82,6 +82,9 @@ import_geoviews, is_mpl_cmap, _find_stack_level, + is_within_latlon_bounds, + convert_latlon_to_mercator, + convert_limit_to_mercator, ) from .utilities import hvplot_extension @@ -1003,6 +1006,17 @@ def __init__( elif projection is False: # to disable automatic projection of tiles self.output_projection = projection + elif tiles and not self.geo and (xlim or ylim): + should_convert = ( + not is_geodataframe(data) + and x is not None + and y is not None + and is_within_latlon_bounds(data, x, y) + ) + + if should_convert: + xlim = convert_limit_to_mercator(xlim, is_x_axis=True) + ylim = convert_limit_to_mercator(ylim, is_x_axis=False) # Operations if resample_when is not None and not any([rasterize, datashade, downsample]): @@ -2529,26 +2543,16 @@ def _process_tiles_without_geo(self, data, x, y): elif is_geodataframe(data): if getattr(data, 'crs', None) is not None: data = data.to_crs(epsg=3857) - else: - min_x = np.min(data[x]) - max_x = np.max(data[x]) - min_y = np.min(data[y]) - max_y = np.max(data[y]) - - x_within_bounds = -180 <= min_x <= 360 and -180 <= max_x <= 360 - y_within_bounds = -90 <= min_y <= 90 and -90 <= max_y <= 90 - if x_within_bounds and y_within_bounds: - data = data.copy() - lons_180 = (data[x] + 180) % 360 - 180 # ticks are better with -180 to 180 - easting, northing = lon_lat_to_easting_northing(lons_180, data[y]) - new_x = 'x' if 'x' not in data else 'x_' # quick existing var check - new_y = 'y' if 'y' not in data else 'y_' - data[new_x] = easting - data[new_y] = northing - if is_xarray(data): - data = data.swap_dims({x: new_x, y: new_y}) - x = new_x - y = new_y + elif is_within_latlon_bounds(data, x, y): + data = data.copy() + easting, northing = convert_latlon_to_mercator(data[x], data[y]) + new_x = 'x' if 'x' not in data else 'x_' + new_y = 'y' if 'y' not in data else 'y_' + data[new_x] = easting + data[new_y] = northing + if is_xarray(data): + data = data.swap_dims({x: new_x, y: new_y}) + x, y = new_x, new_y return data, x, y def chart(self, element, x, y, data=None): diff --git a/hvplot/tests/testgeowithoutgv.py b/hvplot/tests/testgeowithoutgv.py index 07517bced..5e8d27b35 100644 --- a/hvplot/tests/testgeowithoutgv.py +++ b/hvplot/tests/testgeowithoutgv.py @@ -9,6 +9,7 @@ import pytest from hvplot.util import is_geodataframe +from holoviews.util.transform import lon_lat_to_easting_northing try: import dask.dataframe as dd @@ -30,6 +31,29 @@ def simple_df(): return pd.DataFrame(np.random.rand(10, 2), columns=['x', 'y']) +@pytest.fixture +def lat_lon_df(): + return pd.DataFrame( + { + 'lon': [-120.0, -100.0, -80.0], + 'lat': [30.0, 35.0, 40.0], + } + ) + + +@pytest.fixture +def mercator_df(): + x_merc, y_merc = lon_lat_to_easting_northing( + np.array([-120.0, -100.0, -80.0]), np.array([30.0, 35.0, 40.0]) + ) + return pd.DataFrame( + { + 'x': x_merc, + 'y': y_merc, + } + ) + + class TestAnnotationNotGeo: def test_plot_tiles_doesnt_set_geo(self, simple_df): plot = simple_df.hvplot.points('x', 'y', tiles=True) @@ -104,3 +128,84 @@ def test_plot_without_crs(self): assert isinstance(plot.get(1), hv.Polygons) bk_plot = bk_renderer.get_plot(plot) assert bk_plot.projection == 'mercator' # projection enabled due to `tiles=True` + + def test_xlim_ylim_conversion_with_tiles(self, simple_df): + """Test that xlim and ylim are automatically converted to Web Mercator when tiles=True""" + df = pd.DataFrame( + {'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]} + ) + plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70), ylim=(25, 45)) + points = plot.get(1) + + assert 'x' in points.data.columns + assert 'y' in points.data.columns + + xlim_expected_0, _ = lon_lat_to_easting_northing(-130, 0) + xlim_expected_1, _ = lon_lat_to_easting_northing(-70, 0) + _, ylim_expected_0 = lon_lat_to_easting_northing(0, 25) + _, ylim_expected_1 = lon_lat_to_easting_northing(0, 45) + + bk_plot = bk_renderer.get_plot(plot) + x_range_start = bk_plot.handles['plot'].x_range.start + x_range_end = bk_plot.handles['plot'].x_range.end + y_range_start = bk_plot.handles['plot'].y_range.start + y_range_end = bk_plot.handles['plot'].y_range.end + assert x_range_start == xlim_expected_0 + assert x_range_end == xlim_expected_1 + assert y_range_start == ylim_expected_0 + assert y_range_end == ylim_expected_1 + + def test_xlim_only_conversion_with_tiles(self, lat_lon_df): + """xlim should convert even when ylim is not provided.""" + + plot = lat_lon_df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70)) + bk_plot = bk_renderer.get_plot(plot) + + x_start = bk_plot.state.x_range.start + x_end = bk_plot.state.x_range.end + + np.testing.assert_almost_equal(x_start, -14471533.803125564) + np.testing.assert_almost_equal(x_end, -7792364.355529151) + assert x_start < x_end + + def test_ylim_only_conversion_with_tiles(self, lat_lon_df): + """ylim should convert even when xlim is not provided.""" + plot = lat_lon_df.hvplot.points('lon', 'lat', tiles=True, ylim=(25, 45)) + bk_plot = bk_renderer.get_plot(plot) + + y_start = bk_plot.state.y_range.start + y_end = bk_plot.state.y_range.end + + assert y_start > 2_000_000 + assert y_end > 5_000_000 + assert y_start < y_end + + def test_xlim_ylim_not_converted_without_tiles(self, lat_lon_df): + """Test that xlim and ylim are NOT converted when tiles=False""" + plot = lat_lon_df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45)) + bk_plot = bk_renderer.get_plot(plot) + + x_range_start = bk_plot.handles['plot'].x_range.start + x_range_end = bk_plot.handles['plot'].x_range.end + y_range_start = bk_plot.handles['plot'].y_range.start + y_range_end = bk_plot.handles['plot'].y_range.end + + assert x_range_start == -130 + assert x_range_end == -70 + assert y_range_start == 25 + assert y_range_end == 45 + + def test_xlim_ylim_out_of_bounds_not_converted(self, mercator_df): + """Test that xlim and ylim are NOT converted when values are outside lat/lon bounds""" + plot = mercator_df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800)) + bk_plot = bk_renderer.get_plot(plot.get(1)) + + x_range_start = bk_plot.handles['plot'].x_range.start + x_range_end = bk_plot.handles['plot'].x_range.end + y_range_start = bk_plot.handles['plot'].y_range.start + y_range_end = bk_plot.handles['plot'].y_range.end + + assert x_range_start == 1000 + assert x_range_end == 3000 + assert y_range_start == 400 + assert y_range_end == 800 diff --git a/hvplot/tests/testutil.py b/hvplot/tests/testutil.py index 01a46ce82..cdb49512b 100644 --- a/hvplot/tests/testutil.py +++ b/hvplot/tests/testutil.py @@ -25,6 +25,11 @@ _convert_col_names_to_str, instantiate_crs_str, is_geodataframe, + is_within_latlon_bounds, + convert_latlon_to_mercator, + _is_valid_bound, + _bounds_in_range, + convert_limit_to_mercator, ) @@ -389,3 +394,250 @@ def test_is_geodataframe_classic_dataframe(): @pytest.mark.geo def test_geoviews_is_available(): assert import_geoviews() + + +class TestIsWithinLatlonBounds: + """Test is_within_latlon_bounds function.""" + + def test_valid_bounds_dataframe(self): + """Return True for valid lat/lon bounds in DataFrame.""" + df = pd.DataFrame({'lon': [-180, 0, 180], 'lat': [-90, 0, 90]}) + assert is_within_latlon_bounds(df, 'lon', 'lat') + + def test_valid_bounds_dict(self): + """Return True for valid lat/lon bounds in dict.""" + data = {'lon': np.array([0, 90]), 'lat': np.array([45, 60])} + assert is_within_latlon_bounds(data, 'lon', 'lat') + + def test_lon_extended_range(self): + """Return True when lon within -180 to 360 range.""" + df = pd.DataFrame({'lon': [0, 180, 360], 'lat': [0, 45, 90]}) + assert is_within_latlon_bounds(df, 'lon', 'lat') + + def test_lon_boundary_values(self): + """Return True at exact boundary values.""" + df = pd.DataFrame({'lon': [-180, 360], 'lat': [-90, 90]}) + assert is_within_latlon_bounds(df, 'lon', 'lat') + + def test_lon_below_min_bound(self): + """Return False when lon min is below -180.""" + df = pd.DataFrame({'lon': [-181, 0, 90], 'lat': [0, 45, 90]}) + assert not is_within_latlon_bounds(df, 'lon', 'lat') + + def test_lon_above_max_bound(self): + """Return False when lon max exceeds 360.""" + df = pd.DataFrame({'lon': [-180, 0, 361], 'lat': [0, 45, 90]}) + assert not is_within_latlon_bounds(df, 'lon', 'lat') + + def test_lat_below_min_bound(self): + """Return False when lat min is below -90.""" + df = pd.DataFrame({'lon': [-180, 0, 90], 'lat': [-91, 0, 45]}) + assert not is_within_latlon_bounds(df, 'lon', 'lat') + + def test_lat_above_max_bound(self): + """Return False when lat max exceeds 90.""" + df = pd.DataFrame({'lon': [-180, 0, 90], 'lat': [-45, 0, 91]}) + assert not is_within_latlon_bounds(df, 'lon', 'lat') + + def test_missing_column(self): + """Return False and warn when column doesn't exist.""" + df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]}) + with pytest.warns(UserWarning, match='Could not determine lat/lon bounds'): + result = is_within_latlon_bounds(df, 'lon', 'lat') + assert not result + + def test_non_numeric_values(self): + """Return False and warn for non-numeric data.""" + df = pd.DataFrame({'lon': ['a', 'b', 'c'], 'lat': [45, 60, 75]}) + with pytest.warns(UserWarning, match='Could not determine lat/lon bounds'): + result = is_within_latlon_bounds(df, 'lon', 'lat') + assert not result + + +class TestIsValidBound: + """Test _is_valid_bound helper function.""" + + def test_valid_numeric_values(self): + """Return True for valid numeric values.""" + assert _is_valid_bound(0) + assert _is_valid_bound(42) + assert _is_valid_bound(-45.5) + assert _is_valid_bound(3.14) + + def test_invalid_values(self): + """Return False for None and NaN.""" + assert not _is_valid_bound(None) + assert not _is_valid_bound(np.nan) + + def test_string_values(self): + """Return True for string values.""" + assert _is_valid_bound('45') + + +class TestBoundsInRange: + """Test _bounds_in_range helper function.""" + + def test_valid_bounds_in_range(self): + """Return True when both bounds are valid and in range.""" + assert _bounds_in_range(-90, 90, -90, 90) + assert _bounds_in_range(-180, 360, -180, 360) + assert _bounds_in_range(0, 45, -180, 360) + + def test_first_bound_out_of_range(self): + """Return False when first bound exceeds range.""" + assert not _bounds_in_range(-181, 0, -180, 360) + + def test_second_bound_out_of_range(self): + """Return False when second bound exceeds range.""" + assert not _bounds_in_range(0, 361, -180, 360) + + def test_both_bounds_out_of_range(self): + """Return False when both bounds exceed range.""" + assert not _bounds_in_range(-200, 400, -180, 360) + + def test_first_bound_none(self): + """Return False when first bound is None.""" + assert not _bounds_in_range(None, 0, -180, 360) + + def test_second_bound_none(self): + """Return False when second bound is None.""" + assert not _bounds_in_range(0, None, -180, 360) + + def test_first_bound_nan(self): + """Return False when first bound is NaN.""" + assert not _bounds_in_range(np.nan, 0, -180, 360) + + def test_second_bound_nan(self): + """Return False when second bound is NaN.""" + assert not _bounds_in_range(0, np.nan, -180, 360) + + +class TestConvertLatlonToMercator: + """Test convert_latlon_to_mercator function.""" + + def test_prime_meridian(self): + """Convert coordinates at prime meridian and equator.""" + lon = np.array([0]) + lat = np.array([0]) + easting, northing = convert_latlon_to_mercator(lon, lat) + np.testing.assert_almost_equal(easting[0], 0, decimal=5) + np.testing.assert_almost_equal(northing[0], 0, decimal=5) + + def test_multiple_points(self): + """Convert multiple lat/lon points.""" + lon = np.array([-180, 0, 180]) + lat = np.array([-45, 0, 45]) + easting, northing = convert_latlon_to_mercator(lon, lat) + assert len(easting) == 3 and len(northing) == 3 + + def test_lon_normalization_180(self): + """Normalize 180 and -180 to same value.""" + lon1 = np.array([180]) + lon2 = np.array([-180]) + e1, _ = convert_latlon_to_mercator(lon1, np.array([0])) + e2, _ = convert_latlon_to_mercator(lon2, np.array([0])) + np.testing.assert_almost_equal(e1[0], e2[0], decimal=5) + + def test_lon_normalization_270(self): + """Normalize 270 to -90.""" + lon1 = np.array([270]) + lon2 = np.array([-90]) + e1, _ = convert_latlon_to_mercator(lon1, np.array([0])) + e2, _ = convert_latlon_to_mercator(lon2, np.array([0])) + np.testing.assert_almost_equal(e1[0], e2[0], decimal=5) + + def test_returns_tuple(self): + """Return tuple of easting and northing arrays.""" + result = convert_latlon_to_mercator(np.array([0, 45, 90]), np.array([0, 30, 60])) + assert isinstance(result, tuple) and len(result) == 2 + + +class TestConvertLimitToMercator: + """Test convert_limit_to_mercator function.""" + + def test_none_and_empty_limits(self): + """Return None for None or empty limits.""" + assert convert_limit_to_mercator(None, is_x_axis=True) is None + assert convert_limit_to_mercator((), is_x_axis=True) is None + + def test_valid_x_limits(self): + """Convert valid x-axis (longitude) limits.""" + result = convert_limit_to_mercator((-90, 90), is_x_axis=True) + assert result is not None and isinstance(result, tuple) and len(result) == 2 + + def test_valid_y_limits(self): + """Convert valid y-axis (latitude) limits.""" + result = convert_limit_to_mercator((-45, 45), is_x_axis=False) + assert result is not None and isinstance(result, tuple) and len(result) == 2 + + def test_out_of_range_x_limits(self): + """Return original limits when x bounds out of range.""" + limits = (-200, 0) + result = convert_limit_to_mercator(limits, is_x_axis=True) + assert result == limits + + def test_out_of_range_y_limits(self): + """Return original limits when y bounds out of range.""" + limits = (0, 100) + result = convert_limit_to_mercator(limits, is_x_axis=False) + assert result == limits + + def test_x_limits_with_none(self): + """Return original limits when x bound is None.""" + limits = (0, None) + result = convert_limit_to_mercator(limits, is_x_axis=True) + assert result == limits + + def test_y_limits_with_none(self): + """Return original limits when y bound is None.""" + limits = (None, 45) + result = convert_limit_to_mercator(limits, is_x_axis=False) + assert result == limits + + def test_x_limits_with_nan(self): + """Return original limits when x bound is NaN.""" + limits = (0, np.nan) + result = convert_limit_to_mercator(limits, is_x_axis=True) + assert result == limits + + def test_y_limits_with_nan(self): + """Return original limits when y bound is NaN.""" + limits = (np.nan, 45) + result = convert_limit_to_mercator(limits, is_x_axis=False) + assert result == limits + + def test_unpacking_error_too_many_values(self): + """Return original and warn when unpacking too many values.""" + limits = (1, 2, 3) + with pytest.warns(UserWarning, match='Could not convert limits'): + result = convert_limit_to_mercator(limits, is_x_axis=True) + assert result == limits + + def test_unpacking_error_single_value(self): + """Return original and warn when unpacking single value.""" + limits = (45,) + with pytest.warns(UserWarning, match='Could not convert limits'): + result = convert_limit_to_mercator(limits, is_x_axis=False) + assert result == limits + + def test_mercator_y_conversion_changes_values(self): + """Ensure Mercator conversion changes y-axis limit values.""" + original = (-45, 45) + result = convert_limit_to_mercator(original, is_x_axis=False) + assert result != original and result[0] != original[0] + + def test_mercator_x_conversion_changes_values(self): + """Ensure Mercator conversion changes x-axis limit values.""" + original = (-90, 90) + result = convert_limit_to_mercator(original, is_x_axis=True) + assert result != original and result[0] != original[0] + + def test_converted_x_limits_ordered(self): + """Ensure converted x-axis limits maintain order.""" + result = convert_limit_to_mercator((0, 90), is_x_axis=True) + assert result[0] < result[1] + + def test_converted_y_limits_ordered(self): + """Ensure converted y-axis limits maintain order.""" + result = convert_limit_to_mercator((-45, -10), is_x_axis=False) + assert result[0] < result[1] diff --git a/hvplot/util.py b/hvplot/util.py index 91d01796c..e4abe1a99 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -13,6 +13,7 @@ from functools import lru_cache, wraps from importlib.util import find_spec from types import FunctionType +import warnings from packaging.version import Version @@ -21,6 +22,7 @@ import pandas as pd import param import holoviews as hv +from holoviews.util.transform import lon_lat_to_easting_northing try: import panel as pn @@ -410,6 +412,71 @@ def process_crs(crs): ) from Exception(*errors) +def is_within_latlon_bounds(data: pd.DataFrame | dict, x: str, y: str) -> bool: + """ + Return True when finite lat/lon bounds are detected. + If unexpected data is encountered, return False. + """ + try: + min_x = np.min(data[x]) + max_x = np.max(data[x]) + min_y = np.min(data[y]) + max_y = np.max(data[y]) + + x_ok = -180 <= min_x <= 360 and -180 <= max_x <= 360 + y_ok = -90 <= min_y <= 90 and -90 <= max_y <= 90 + return x_ok and y_ok + except Exception as e: + warnings.warn(f'Could not determine lat/lon bounds: {e}') + return False + + +def convert_latlon_to_mercator(lon: np.ndarray, lat: np.ndarray): + """Convert lon/lat values to Web Mercator easting/northing.""" + # ticks are better with -180 to 180 + lon_normalized = (lon + 180) % 360 - 180 + return lon_lat_to_easting_northing(lon_normalized, lat) + + +def _is_valid_bound(v): + """ + Check if a bound value is valid (not None or NaN). + """ + return v is not None and not (isinstance(v, float) and np.isnan(v)) + + +def _bounds_in_range(v0, v1, min_val, max_val): + """ + Check if both bounds are valid and in range. + """ + if not (_is_valid_bound(v0) and _is_valid_bound(v1)): + return False + return min_val <= v0 <= max_val and min_val <= v1 <= max_val + + +def convert_limit_to_mercator(limit: tuple | None, is_x_axis=True) -> tuple | None: + """Convert axis limits to Web Mercator coordinates when possible.""" + if not limit: + return None + + try: + v0, v1 = limit + + if is_x_axis: + if not _bounds_in_range(v0, v1, -180, 360): + return limit + (v0_merc, v1_merc), _ = convert_latlon_to_mercator(np.array([v0, v1]), (0, 0)) + else: + if not _bounds_in_range(v0, v1, -90, 90): + return limit + _, (v0_merc, v1_merc) = convert_latlon_to_mercator(np.array([0, 0]), (v0, v1)) + + return (v0_merc, v1_merc) + except Exception as e: + warnings.warn(f'Could not convert limits to Web Mercator: {e}') + return limit + + def is_list_like(obj): """ Adapted from pandas' is_list_like cython function.