From 289c5c15cd1f5456cf35a536568276581b657966 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:10:10 +0000 Subject: [PATCH 01/11] Initial plan From be6a030d35f799dc2a7de4ee6c1cf3c647c147e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:21:25 +0000 Subject: [PATCH 02/11] Add automatic xlim/ylim conversion to Web Mercator when tiles=True Co-authored-by: ahuang11 <15331990+ahuang11@users.noreply.github.com> --- hvplot/converter.py | 25 ++++++++++++++ hvplot/tests/testgeowithoutgv.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/hvplot/converter.py b/hvplot/converter.py index 361b55da5..7d9215cb2 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -1003,6 +1003,31 @@ 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): + # Automatically convert xlim/ylim to Web Mercator when tiles are used + # without geo=True, similar to how data coordinates are converted + # Check if xlim is within lat/lon bounds and convert + if xlim: + x0, x1 = xlim + xlim_in_bounds = -180 <= x0 <= 360 and -180 <= x1 <= 360 + if xlim_in_bounds: + # Normalize to -180 to 180 range for better ticks + x0_norm = (x0 + 180) % 360 - 180 + x1_norm = (x1 + 180) % 360 - 180 + # Convert to Web Mercator (easting only depends on longitude) + x0_merc, _ = lon_lat_to_easting_northing(x0_norm, 0) + x1_merc, _ = lon_lat_to_easting_northing(x1_norm, 0) + xlim = (x0_merc, x1_merc) + + # Check if ylim is within lat/lon bounds and convert + if ylim: + y0, y1 = ylim + ylim_in_bounds = -90 <= y0 <= 90 and -90 <= y1 <= 90 + if ylim_in_bounds: + # Convert to Web Mercator (northing only depends on latitude) + _, y0_merc = lon_lat_to_easting_northing(0, y0) + _, y1_merc = lon_lat_to_easting_northing(0, y1) + ylim = (y0_merc, y1_merc) # Operations if resample_when is not None and not any([rasterize, datashade, downsample]): diff --git a/hvplot/tests/testgeowithoutgv.py b/hvplot/tests/testgeowithoutgv.py index 07517bced..40aa2290f 100644 --- a/hvplot/tests/testgeowithoutgv.py +++ b/hvplot/tests/testgeowithoutgv.py @@ -104,3 +104,59 @@ 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""" + from holoviews.util.transform import lon_lat_to_easting_northing + + # Create a dataframe with lat/lon-like data + df = pd.DataFrame({ + 'lon': [-120.0, -100.0, -80.0], + 'lat': [30.0, 35.0, 40.0], + 'value': [1, 2, 3] + }) + + # Plot with xlim and ylim in lat/lon coordinates + plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70), ylim=(25, 45)) + + # Get the Points element from the overlay + points = plot.get(1) + + # Check that the coordinates were converted + assert 'lon' not in points.data.columns + assert 'lat' not in points.data.columns + + # Calculate expected xlim and ylim in Web Mercator + 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) + + # Get the plot opts + bk_plot = bk_renderer.get_plot(plot) + + # Check that xlim and ylim were converted + # The plot should have x_range and y_range set to the converted values + assert hasattr(bk_plot.handles['plot'], 'x_range') + assert hasattr(bk_plot.handles['plot'], 'y_range') + + # Verify the ranges are in Web Mercator (much larger values than lat/lon) + 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 + + # Web Mercator values should be much larger than lat/lon + # xlim in Web Mercator should be around -14e6 to -7e6 + assert abs(x_range_start) > 10000000 # Much larger than -130 + assert abs(x_range_end) > 7000000 # Much larger than -70 + + # ylim in Web Mercator should be around 2.8e6 to 5.6e6 + assert y_range_start > 2000000 # Much larger than 25 + assert y_range_end > 5000000 # Much larger than 45 + + # Check that the values are approximately correct + assert abs(x_range_start - xlim_expected_0) < 100000 + assert abs(x_range_end - xlim_expected_1) < 100000 + assert abs(y_range_start - ylim_expected_0) < 100000 + assert abs(y_range_end - ylim_expected_1) < 100000 From faf4428087805a57c85944dc27c9300ba92abaf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:24:20 +0000 Subject: [PATCH 03/11] Add comprehensive tests for xlim/ylim conversion scenarios Co-authored-by: ahuang11 <15331990+ahuang11@users.noreply.github.com> --- hvplot/tests/testgeowithoutgv.py | 58 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/hvplot/tests/testgeowithoutgv.py b/hvplot/tests/testgeowithoutgv.py index 40aa2290f..ae8749898 100644 --- a/hvplot/tests/testgeowithoutgv.py +++ b/hvplot/tests/testgeowithoutgv.py @@ -123,8 +123,8 @@ def test_xlim_ylim_conversion_with_tiles(self, simple_df): points = plot.get(1) # Check that the coordinates were converted - assert 'lon' not in points.data.columns - assert 'lat' not in points.data.columns + assert 'x' in points.data.columns or 'x_' in points.data.columns + assert 'y' in points.data.columns or 'y_' in points.data.columns # Calculate expected xlim and ylim in Web Mercator xlim_expected_0, _ = lon_lat_to_easting_northing(-130, 0) @@ -160,3 +160,57 @@ def test_xlim_ylim_conversion_with_tiles(self, simple_df): assert abs(x_range_end - xlim_expected_1) < 100000 assert abs(y_range_start - ylim_expected_0) < 100000 assert abs(y_range_end - ylim_expected_1) < 100000 + + def test_xlim_ylim_not_converted_without_tiles(self): + """Test that xlim and ylim are NOT converted when tiles=False""" + # Create a dataframe with lat/lon-like data + df = pd.DataFrame({ + 'lon': [-120.0, -100.0, -80.0], + 'lat': [30.0, 35.0, 40.0], + 'value': [1, 2, 3] + }) + + # Plot without tiles - xlim/ylim should NOT be converted + plot = df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45)) + + # Get the plot opts - note: without tiles it's a Points element, not an overlay + bk_plot = bk_renderer.get_plot(plot) + + # Check that xlim and ylim were NOT converted (still in lat/lon range) + 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 + + # Values should still be in lat/lon range + assert -140 < x_range_start < -120 + assert -80 < x_range_end < -60 + assert 20 < y_range_start < 30 + assert 40 < y_range_end < 50 + + def test_xlim_ylim_out_of_bounds_not_converted(self): + """Test that xlim and ylim are NOT converted when values are outside lat/lon bounds""" + # Create a dataframe with arbitrary data + df = pd.DataFrame({ + 'x': [1000.0, 2000.0, 3000.0], + 'y': [500.0, 600.0, 700.0], + 'value': [1, 2, 3] + }) + + # Plot with tiles but xlim/ylim outside lat/lon bounds + plot = df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800)) + + # Get the plot opts + bk_plot = bk_renderer.get_plot(plot.get(1)) + + # Check that xlim and ylim were NOT converted (still in original range) + 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 + + # Values should still be in original range (not Web Mercator) + assert 900 < x_range_start < 1100 + assert 2900 < x_range_end < 3100 + assert 300 < y_range_start < 500 + assert 700 < y_range_end < 900 From d1d6e6b9d94eae1c4aa329f048dc6b868b65226b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:31:42 +0000 Subject: [PATCH 04/11] Fix xlim/ylim conversion to check data bounds first Co-authored-by: ahuang11 <15331990+ahuang11@users.noreply.github.com> --- hvplot/converter.py | 61 +++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/hvplot/converter.py b/hvplot/converter.py index 7d9215cb2..7bb57d488 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -1006,28 +1006,47 @@ def __init__( elif tiles and not self.geo and (xlim or ylim): # Automatically convert xlim/ylim to Web Mercator when tiles are used # without geo=True, similar to how data coordinates are converted - # Check if xlim is within lat/lon bounds and convert - if xlim: - x0, x1 = xlim - xlim_in_bounds = -180 <= x0 <= 360 and -180 <= x1 <= 360 - if xlim_in_bounds: - # Normalize to -180 to 180 range for better ticks - x0_norm = (x0 + 180) % 360 - 180 - x1_norm = (x1 + 180) % 360 - 180 - # Convert to Web Mercator (easting only depends on longitude) - x0_merc, _ = lon_lat_to_easting_northing(x0_norm, 0) - x1_merc, _ = lon_lat_to_easting_northing(x1_norm, 0) - xlim = (x0_merc, x1_merc) + # First check if the data itself is in lat/lon bounds (same logic as _process_tiles_without_geo) + should_convert = False + if not is_geodataframe(data) and x is not None and y is not None: + try: + if not is_lazy_data(data): + # Check data bounds to determine if conversion should happen + 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 + should_convert = x_within_bounds and y_within_bounds + except (KeyError, ValueError, TypeError): + # If we can't check data bounds, don't convert + pass - # Check if ylim is within lat/lon bounds and convert - if ylim: - y0, y1 = ylim - ylim_in_bounds = -90 <= y0 <= 90 and -90 <= y1 <= 90 - if ylim_in_bounds: - # Convert to Web Mercator (northing only depends on latitude) - _, y0_merc = lon_lat_to_easting_northing(0, y0) - _, y1_merc = lon_lat_to_easting_northing(0, y1) - ylim = (y0_merc, y1_merc) + # Only convert xlim/ylim if data is in lat/lon bounds + if should_convert: + # Check if xlim is within lat/lon bounds and convert + if xlim: + x0, x1 = xlim + xlim_in_bounds = -180 <= x0 <= 360 and -180 <= x1 <= 360 + if xlim_in_bounds: + # Normalize to -180 to 180 range for better ticks + x0_norm = (x0 + 180) % 360 - 180 + x1_norm = (x1 + 180) % 360 - 180 + # Convert to Web Mercator (easting only depends on longitude) + x0_merc, _ = lon_lat_to_easting_northing(x0_norm, 0) + x1_merc, _ = lon_lat_to_easting_northing(x1_norm, 0) + xlim = (x0_merc, x1_merc) + + # Check if ylim is within lat/lon bounds and convert + if ylim: + y0, y1 = ylim + ylim_in_bounds = -90 <= y0 <= 90 and -90 <= y1 <= 90 + if ylim_in_bounds: + # Convert to Web Mercator (northing only depends on latitude) + _, y0_merc = lon_lat_to_easting_northing(0, y0) + _, y1_merc = lon_lat_to_easting_northing(0, y1) + ylim = (y0_merc, y1_merc) # Operations if resample_when is not None and not any([rasterize, datashade, downsample]): From f55a2e42a89bcd8dcf4364ad38f4eeba3c24bca8 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 10 Oct 2025 10:46:24 -0700 Subject: [PATCH 05/11] clean up and tests --- hvplot/converter.py | 71 +++------------------ hvplot/tests/testgeowithoutgv.py | 102 ++++++++++++++++++++----------- hvplot/util.py | 84 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 98 deletions(-) diff --git a/hvplot/converter.py b/hvplot/converter.py index 7bb57d488..9b020e2bb 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, + _convert_limits_for_tiles, + _is_within_latlon_bounds, + _transform_data_to_mercator, ) from .utilities import hvplot_extension @@ -1004,49 +1007,7 @@ def __init__( # to disable automatic projection of tiles self.output_projection = projection elif tiles and not self.geo and (xlim or ylim): - # Automatically convert xlim/ylim to Web Mercator when tiles are used - # without geo=True, similar to how data coordinates are converted - # First check if the data itself is in lat/lon bounds (same logic as _process_tiles_without_geo) - should_convert = False - if not is_geodataframe(data) and x is not None and y is not None: - try: - if not is_lazy_data(data): - # Check data bounds to determine if conversion should happen - 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 - should_convert = x_within_bounds and y_within_bounds - except (KeyError, ValueError, TypeError): - # If we can't check data bounds, don't convert - pass - - # Only convert xlim/ylim if data is in lat/lon bounds - if should_convert: - # Check if xlim is within lat/lon bounds and convert - if xlim: - x0, x1 = xlim - xlim_in_bounds = -180 <= x0 <= 360 and -180 <= x1 <= 360 - if xlim_in_bounds: - # Normalize to -180 to 180 range for better ticks - x0_norm = (x0 + 180) % 360 - 180 - x1_norm = (x1 + 180) % 360 - 180 - # Convert to Web Mercator (easting only depends on longitude) - x0_merc, _ = lon_lat_to_easting_northing(x0_norm, 0) - x1_merc, _ = lon_lat_to_easting_northing(x1_norm, 0) - xlim = (x0_merc, x1_merc) - - # Check if ylim is within lat/lon bounds and convert - if ylim: - y0, y1 = ylim - ylim_in_bounds = -90 <= y0 <= 90 and -90 <= y1 <= 90 - if ylim_in_bounds: - # Convert to Web Mercator (northing only depends on latitude) - _, y0_merc = lon_lat_to_easting_northing(0, y0) - _, y1_merc = lon_lat_to_easting_northing(0, y1) - ylim = (y0_merc, y1_merc) + xlim, ylim = _convert_limits_for_tiles(data, x, y, xlim, ylim) # Operations if resample_when is not None and not any([rasterize, datashade, downsample]): @@ -2573,26 +2534,8 @@ 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, x, y = _transform_data_to_mercator(data, x, 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 ae8749898..71f452623 100644 --- a/hvplot/tests/testgeowithoutgv.py +++ b/hvplot/tests/testgeowithoutgv.py @@ -108,80 +108,116 @@ def test_plot_without_crs(self): def test_xlim_ylim_conversion_with_tiles(self, simple_df): """Test that xlim and ylim are automatically converted to Web Mercator when tiles=True""" from holoviews.util.transform import lon_lat_to_easting_northing - + # Create a dataframe with lat/lon-like data - df = pd.DataFrame({ - 'lon': [-120.0, -100.0, -80.0], - 'lat': [30.0, 35.0, 40.0], - 'value': [1, 2, 3] - }) - + df = pd.DataFrame( + {'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]} + ) + # Plot with xlim and ylim in lat/lon coordinates plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70), ylim=(25, 45)) - + # Get the Points element from the overlay points = plot.get(1) - + # Check that the coordinates were converted assert 'x' in points.data.columns or 'x_' in points.data.columns assert 'y' in points.data.columns or 'y_' in points.data.columns - + # Calculate expected xlim and ylim in Web Mercator 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) - + # Get the plot opts bk_plot = bk_renderer.get_plot(plot) - + # Check that xlim and ylim were converted # The plot should have x_range and y_range set to the converted values assert hasattr(bk_plot.handles['plot'], 'x_range') assert hasattr(bk_plot.handles['plot'], 'y_range') - + # Verify the ranges are in Web Mercator (much larger values than lat/lon) 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 - + # Web Mercator values should be much larger than lat/lon # xlim in Web Mercator should be around -14e6 to -7e6 assert abs(x_range_start) > 10000000 # Much larger than -130 assert abs(x_range_end) > 7000000 # Much larger than -70 - + # ylim in Web Mercator should be around 2.8e6 to 5.6e6 assert y_range_start > 2000000 # Much larger than 25 assert y_range_end > 5000000 # Much larger than 45 - + # Check that the values are approximately correct assert abs(x_range_start - xlim_expected_0) < 100000 assert abs(x_range_end - xlim_expected_1) < 100000 assert abs(y_range_start - ylim_expected_0) < 100000 assert abs(y_range_end - ylim_expected_1) < 100000 + def test_xlim_only_conversion_with_tiles(self, simple_df): + """xlim should convert even when ylim is not provided.""" + + df = pd.DataFrame( + { + 'lon': [-120.0, -100.0, -80.0], + 'lat': [30.0, 35.0, 40.0], + } + ) + + plot = 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 + + assert abs(x_start) > 10_000_000 + assert abs(x_end) > 7_000_000 + assert x_start < x_end + + def test_ylim_only_conversion_with_tiles(self, simple_df): + """ylim should convert even when xlim is not provided.""" + + df = pd.DataFrame( + { + 'lon': [-120.0, -100.0, -80.0], + 'lat': [30.0, 35.0, 40.0], + } + ) + + plot = 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): """Test that xlim and ylim are NOT converted when tiles=False""" # Create a dataframe with lat/lon-like data - df = pd.DataFrame({ - 'lon': [-120.0, -100.0, -80.0], - 'lat': [30.0, 35.0, 40.0], - 'value': [1, 2, 3] - }) - + df = pd.DataFrame( + {'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]} + ) + # Plot without tiles - xlim/ylim should NOT be converted plot = df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45)) - + # Get the plot opts - note: without tiles it's a Points element, not an overlay bk_plot = bk_renderer.get_plot(plot) - + # Check that xlim and ylim were NOT converted (still in lat/lon range) 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 - + # Values should still be in lat/lon range assert -140 < x_range_start < -120 assert -80 < x_range_end < -60 @@ -191,24 +227,22 @@ def test_xlim_ylim_not_converted_without_tiles(self): def test_xlim_ylim_out_of_bounds_not_converted(self): """Test that xlim and ylim are NOT converted when values are outside lat/lon bounds""" # Create a dataframe with arbitrary data - df = pd.DataFrame({ - 'x': [1000.0, 2000.0, 3000.0], - 'y': [500.0, 600.0, 700.0], - 'value': [1, 2, 3] - }) - + df = pd.DataFrame( + {'x': [1000.0, 2000.0, 3000.0], 'y': [500.0, 600.0, 700.0], 'value': [1, 2, 3]} + ) + # Plot with tiles but xlim/ylim outside lat/lon bounds plot = df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800)) - + # Get the plot opts bk_plot = bk_renderer.get_plot(plot.get(1)) - + # Check that xlim and ylim were NOT converted (still in original range) 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 - + # Values should still be in original range (not Web Mercator) assert 900 < x_range_start < 1100 assert 2900 < x_range_end < 3100 diff --git a/hvplot/util.py b/hvplot/util.py index 91d01796c..015016762 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -21,6 +21,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 +411,89 @@ def process_crs(crs): ) from Exception(*errors) +def _is_within_latlon_bounds(data, x, y): + """Return True when finite lat/lon bounds are detected.""" + + if is_lazy_data(data): + 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]) + except (KeyError, ValueError, TypeError): + return False + + x_ok = -180 <= min_x <= 360 and -180 <= max_x <= 360 + y_ok = -90 <= min_y <= 90 and -90 <= max_y <= 90 + return bool(x_ok and y_ok) + + +def _convert_latlon_to_mercator(lon, lat): + """Convert lon/lat values to Web Mercator easting/northing.""" + lon_normalized = (lon + 180) % 360 - 180 + return lon_lat_to_easting_northing(lon_normalized, lat) + + +def _convert_limit_to_mercator(limit, is_x_axis=True): + """Convert axis limits to Web Mercator coordinates when possible.""" + + if not limit: + return None + + try: + v0, v1 = limit + except (TypeError, ValueError): + return limit + + if is_x_axis: + if not (-180 <= v0 <= 360 and -180 <= v1 <= 360): + return limit + v0_merc, _ = _convert_latlon_to_mercator(v0, 0) + v1_merc, _ = _convert_latlon_to_mercator(v1, 0) + else: + if not (-90 <= v0 <= 90 and -90 <= v1 <= 90): + return limit + _, v0_merc = _convert_latlon_to_mercator(0, v0) + _, v1_merc = _convert_latlon_to_mercator(0, v1) + + return (v0_merc, v1_merc) + + +def _transform_data_to_mercator(data, x, y): + """Project data columns from lon/lat to Web Mercator.""" + + data = data.copy() + easting, northing = lon_lat_to_easting_northing(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}) + return data, new_x, new_y + + +def _convert_limits_for_tiles(data, x, y, xlim, ylim): + """Convert axis limits when tiles are enabled without geo=True.""" + + 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 not should_convert: + return xlim, ylim + + converted_xlim = _convert_limit_to_mercator(xlim, is_x_axis=True) + converted_ylim = _convert_limit_to_mercator(ylim, is_x_axis=False) + return converted_xlim, converted_ylim + + def is_list_like(obj): """ Adapted from pandas' is_list_like cython function. From 3db249d0ea933b731db51553f66142e819432810 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 10 Oct 2025 11:00:48 -0700 Subject: [PATCH 06/11] clean up and fixes --- hvplot/converter.py | 29 ++++++++++++++++++++++------ hvplot/util.py | 47 +++++++-------------------------------------- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/hvplot/converter.py b/hvplot/converter.py index 9b020e2bb..49a51f468 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -82,9 +82,9 @@ import_geoviews, is_mpl_cmap, _find_stack_level, - _convert_limits_for_tiles, - _is_within_latlon_bounds, - _transform_data_to_mercator, + is_within_latlon_bounds, + convert_latlon_to_mercator, + convert_limit_to_mercator, ) from .utilities import hvplot_extension @@ -1007,7 +1007,16 @@ def __init__( # to disable automatic projection of tiles self.output_projection = projection elif tiles and not self.geo and (xlim or ylim): - xlim, ylim = _convert_limits_for_tiles(data, x, y, xlim, 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]): @@ -2534,8 +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) - elif _is_within_latlon_bounds(data, x, y): - data, x, y = _transform_data_to_mercator(data, x, 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/util.py b/hvplot/util.py index 015016762..df8c22d4a 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -411,7 +411,7 @@ def process_crs(crs): ) from Exception(*errors) -def _is_within_latlon_bounds(data, x, y): +def is_within_latlon_bounds(data, x, y): """Return True when finite lat/lon bounds are detected.""" if is_lazy_data(data): @@ -430,13 +430,13 @@ def _is_within_latlon_bounds(data, x, y): return bool(x_ok and y_ok) -def _convert_latlon_to_mercator(lon, lat): +def convert_latlon_to_mercator(lon, lat): """Convert lon/lat values to Web Mercator easting/northing.""" lon_normalized = (lon + 180) % 360 - 180 return lon_lat_to_easting_northing(lon_normalized, lat) -def _convert_limit_to_mercator(limit, is_x_axis=True): +def convert_limit_to_mercator(limit, is_x_axis=True): """Convert axis limits to Web Mercator coordinates when possible.""" if not limit: @@ -450,50 +450,17 @@ def _convert_limit_to_mercator(limit, is_x_axis=True): if is_x_axis: if not (-180 <= v0 <= 360 and -180 <= v1 <= 360): return limit - v0_merc, _ = _convert_latlon_to_mercator(v0, 0) - v1_merc, _ = _convert_latlon_to_mercator(v1, 0) + v0_merc, _ = convert_latlon_to_mercator(v0, 0) + v1_merc, _ = convert_latlon_to_mercator(v1, 0) else: if not (-90 <= v0 <= 90 and -90 <= v1 <= 90): return limit - _, v0_merc = _convert_latlon_to_mercator(0, v0) - _, v1_merc = _convert_latlon_to_mercator(0, v1) + _, v0_merc = convert_latlon_to_mercator(0, v0) + _, v1_merc = convert_latlon_to_mercator(0, v1) return (v0_merc, v1_merc) -def _transform_data_to_mercator(data, x, y): - """Project data columns from lon/lat to Web Mercator.""" - - data = data.copy() - easting, northing = lon_lat_to_easting_northing(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}) - return data, new_x, new_y - - -def _convert_limits_for_tiles(data, x, y, xlim, ylim): - """Convert axis limits when tiles are enabled without geo=True.""" - - 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 not should_convert: - return xlim, ylim - - converted_xlim = _convert_limit_to_mercator(xlim, is_x_axis=True) - converted_ylim = _convert_limit_to_mercator(ylim, is_x_axis=False) - return converted_xlim, converted_ylim - - def is_list_like(obj): """ Adapted from pandas' is_list_like cython function. From 5f503b5e8c79db3fb6aaf1c1d26ca31d28653390 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 10 Oct 2025 11:02:11 -0700 Subject: [PATCH 07/11] remove dup check --- hvplot/util.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/hvplot/util.py b/hvplot/util.py index df8c22d4a..e9fff9f75 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -413,10 +413,6 @@ def process_crs(crs): def is_within_latlon_bounds(data, x, y): """Return True when finite lat/lon bounds are detected.""" - - if is_lazy_data(data): - return False - try: min_x = np.min(data[x]) max_x = np.max(data[x]) From de2de9dd70dedeffcf0936008996778736e2b386 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Fri, 10 Oct 2025 11:07:48 -0700 Subject: [PATCH 08/11] reduce call --- hvplot/util.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/hvplot/util.py b/hvplot/util.py index e9fff9f75..03d16dfd5 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -446,13 +446,11 @@ def convert_limit_to_mercator(limit, is_x_axis=True): if is_x_axis: if not (-180 <= v0 <= 360 and -180 <= v1 <= 360): return limit - v0_merc, _ = convert_latlon_to_mercator(v0, 0) - v1_merc, _ = convert_latlon_to_mercator(v1, 0) + (v0_merc, v1_merc), _ = convert_latlon_to_mercator(np.array([v0, v1]), (0, 0)) else: if not (-90 <= v0 <= 90 and -90 <= v1 <= 90): return limit - _, v0_merc = convert_latlon_to_mercator(0, v0) - _, v1_merc = convert_latlon_to_mercator(0, v1) + _, (v0_merc, v1_merc) = convert_latlon_to_mercator(np.array([0, 0]), (v0, v1)) return (v0_merc, v1_merc) From 5f4f4a8114f19c1ac8451574e92edcffa8e95421 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Tue, 14 Oct 2025 08:08:56 -0700 Subject: [PATCH 09/11] Add doc --- doc/gallery/geospatial/map_overlay.ipynb | 31 ++++++++++++++++++++++++ doc/ref/plotting_options/axis.ipynb | 4 ++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/doc/gallery/geospatial/map_overlay.ipynb b/doc/gallery/geospatial/map_overlay.ipynb index 001236b65..2173139ff 100644 --- a/doc/gallery/geospatial/map_overlay.ipynb +++ b/doc/gallery/geospatial/map_overlay.ipynb @@ -35,6 +35,37 @@ ")" ] }, + { + "cell_type": "markdown", + "id": "8c428430", + "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": "4fa7dd18", + "metadata": {}, + "outputs": [], + "source": [ + "df.hvplot.points(\n", + " x='lon',\n", + " y='lat',\n", + " xlim=(80, 120),\n", + " ylim=(-15, 30),\n", + " tiles=True,\n", + " tiles_opts={'alpha': 0.3},\n", + " alpha=0.6,\n", + " color='mag',\n", + " cmap='viridis_r',\n", + " title='Earthquake Map Overlay (Bokeh)',\n", + " xlabel='Longitude',\n", + " ylabel='Latitude',\n", + ")" + ] + }, { "cell_type": "markdown", "id": "753fbf24-e6e8-4df6-ae19-1ae040aeeed5", diff --git a/doc/ref/plotting_options/axis.ipynb b/doc/ref/plotting_options/axis.ipynb index 9bda27c74..c80412536 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` is provided and the `xlim` and `ylim` are in `lat`/`lon` coordinates, they will be automatically transformed to Web Mercator coordinates." ] }, { From 51026d6b78be9a20b202564a6b959e2985d035ad Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 22 Oct 2025 16:19:49 -0700 Subject: [PATCH 10/11] address comments --- doc/gallery/geospatial/map_overlay.ipynb | 31 ----- doc/ref/plotting_options/axis.ipynb | 2 +- doc/ref/plotting_options/geographic.ipynb | 31 +++++ hvplot/tests/testgeowithoutgv.py | 135 ++++++++-------------- hvplot/util.py | 41 +++++-- 5 files changed, 112 insertions(+), 128 deletions(-) diff --git a/doc/gallery/geospatial/map_overlay.ipynb b/doc/gallery/geospatial/map_overlay.ipynb index 2173139ff..001236b65 100644 --- a/doc/gallery/geospatial/map_overlay.ipynb +++ b/doc/gallery/geospatial/map_overlay.ipynb @@ -35,37 +35,6 @@ ")" ] }, - { - "cell_type": "markdown", - "id": "8c428430", - "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": "4fa7dd18", - "metadata": {}, - "outputs": [], - "source": [ - "df.hvplot.points(\n", - " x='lon',\n", - " y='lat',\n", - " xlim=(80, 120),\n", - " ylim=(-15, 30),\n", - " tiles=True,\n", - " tiles_opts={'alpha': 0.3},\n", - " alpha=0.6,\n", - " color='mag',\n", - " cmap='viridis_r',\n", - " title='Earthquake Map Overlay (Bokeh)',\n", - " xlabel='Longitude',\n", - " ylabel='Latitude',\n", - ")" - ] - }, { "cell_type": "markdown", "id": "753fbf24-e6e8-4df6-ae19-1ae040aeeed5", diff --git a/doc/ref/plotting_options/axis.ipynb b/doc/ref/plotting_options/axis.ipynb index c80412536..06cda67b9 100644 --- a/doc/ref/plotting_options/axis.ipynb +++ b/doc/ref/plotting_options/axis.ipynb @@ -883,7 +883,7 @@ "\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).\n", "\n", - "If `tiles` is provided and the `xlim` and `ylim` are in `lat`/`lon` coordinates, they will be automatically transformed to Web Mercator coordinates." + "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/tests/testgeowithoutgv.py b/hvplot/tests/testgeowithoutgv.py index 71f452623..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) @@ -107,89 +131,46 @@ def test_plot_without_crs(self): def test_xlim_ylim_conversion_with_tiles(self, simple_df): """Test that xlim and ylim are automatically converted to Web Mercator when tiles=True""" - from holoviews.util.transform import lon_lat_to_easting_northing - - # Create a dataframe with lat/lon-like data df = pd.DataFrame( {'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]} ) - - # Plot with xlim and ylim in lat/lon coordinates plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70), ylim=(25, 45)) - - # Get the Points element from the overlay points = plot.get(1) - # Check that the coordinates were converted - assert 'x' in points.data.columns or 'x_' in points.data.columns - assert 'y' in points.data.columns or 'y_' in points.data.columns + assert 'x' in points.data.columns + assert 'y' in points.data.columns - # Calculate expected xlim and ylim in Web Mercator 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) - # Get the plot opts bk_plot = bk_renderer.get_plot(plot) - - # Check that xlim and ylim were converted - # The plot should have x_range and y_range set to the converted values - assert hasattr(bk_plot.handles['plot'], 'x_range') - assert hasattr(bk_plot.handles['plot'], 'y_range') - - # Verify the ranges are in Web Mercator (much larger values than lat/lon) 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 - # Web Mercator values should be much larger than lat/lon - # xlim in Web Mercator should be around -14e6 to -7e6 - assert abs(x_range_start) > 10000000 # Much larger than -130 - assert abs(x_range_end) > 7000000 # Much larger than -70 - - # ylim in Web Mercator should be around 2.8e6 to 5.6e6 - assert y_range_start > 2000000 # Much larger than 25 - assert y_range_end > 5000000 # Much larger than 45 - - # Check that the values are approximately correct - assert abs(x_range_start - xlim_expected_0) < 100000 - assert abs(x_range_end - xlim_expected_1) < 100000 - assert abs(y_range_start - ylim_expected_0) < 100000 - assert abs(y_range_end - ylim_expected_1) < 100000 - - def test_xlim_only_conversion_with_tiles(self, simple_df): + def test_xlim_only_conversion_with_tiles(self, lat_lon_df): """xlim should convert even when ylim is not provided.""" - df = pd.DataFrame( - { - 'lon': [-120.0, -100.0, -80.0], - 'lat': [30.0, 35.0, 40.0], - } - ) - - plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70)) + 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 - assert abs(x_start) > 10_000_000 - assert abs(x_end) > 7_000_000 + 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, simple_df): + def test_ylim_only_conversion_with_tiles(self, lat_lon_df): """ylim should convert even when xlim is not provided.""" - - df = pd.DataFrame( - { - 'lon': [-120.0, -100.0, -80.0], - 'lat': [30.0, 35.0, 40.0], - } - ) - - plot = df.hvplot.points('lon', 'lat', tiles=True, ylim=(25, 45)) + 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 @@ -199,52 +180,32 @@ def test_ylim_only_conversion_with_tiles(self, simple_df): assert y_end > 5_000_000 assert y_start < y_end - def test_xlim_ylim_not_converted_without_tiles(self): + def test_xlim_ylim_not_converted_without_tiles(self, lat_lon_df): """Test that xlim and ylim are NOT converted when tiles=False""" - # Create a dataframe with lat/lon-like data - df = pd.DataFrame( - {'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]} - ) - - # Plot without tiles - xlim/ylim should NOT be converted - plot = df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45)) - - # Get the plot opts - note: without tiles it's a Points element, not an overlay + plot = lat_lon_df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45)) bk_plot = bk_renderer.get_plot(plot) - # Check that xlim and ylim were NOT converted (still in lat/lon range) 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 - # Values should still be in lat/lon range - assert -140 < x_range_start < -120 - assert -80 < x_range_end < -60 - assert 20 < y_range_start < 30 - assert 40 < y_range_end < 50 + 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): + 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""" - # Create a dataframe with arbitrary data - df = pd.DataFrame( - {'x': [1000.0, 2000.0, 3000.0], 'y': [500.0, 600.0, 700.0], 'value': [1, 2, 3]} - ) - - # Plot with tiles but xlim/ylim outside lat/lon bounds - plot = df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800)) - - # Get the plot opts + plot = mercator_df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800)) bk_plot = bk_renderer.get_plot(plot.get(1)) - # Check that xlim and ylim were NOT converted (still in original range) 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 - # Values should still be in original range (not Web Mercator) - assert 900 < x_range_start < 1100 - assert 2900 < x_range_end < 3100 - assert 300 < y_range_start < 500 - assert 700 < y_range_end < 900 + assert x_range_start == 1000 + assert x_range_end == 3000 + assert y_range_start == 400 + assert y_range_end == 800 diff --git a/hvplot/util.py b/hvplot/util.py index 03d16dfd5..0270e1a66 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 @@ -411,28 +412,49 @@ def process_crs(crs): ) from Exception(*errors) -def is_within_latlon_bounds(data, x, y): - """Return True when finite lat/lon bounds are detected.""" +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]) - except (KeyError, ValueError, TypeError): + except Exception as e: + warnings.warn(f'Could not determine lat/lon bounds: {e}') return False x_ok = -180 <= min_x <= 360 and -180 <= max_x <= 360 y_ok = -90 <= min_y <= 90 and -90 <= max_y <= 90 - return bool(x_ok and y_ok) + return x_ok and y_ok -def convert_latlon_to_mercator(lon, lat): +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 convert_limit_to_mercator(limit, is_x_axis=True): +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: @@ -440,15 +462,16 @@ def convert_limit_to_mercator(limit, is_x_axis=True): try: v0, v1 = limit - except (TypeError, ValueError): + except Exception as e: + warnings.warn(f'Could not unpack limit {limit}: {e}') return limit if is_x_axis: - if not (-180 <= v0 <= 360 and -180 <= v1 <= 360): + 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 (-90 <= v0 <= 90 and -90 <= v1 <= 90): + 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)) From fa1368066a0ae63505e42b7533aaff87f0c02bd7 Mon Sep 17 00:00:00 2001 From: Andrew Huang Date: Wed, 22 Oct 2025 16:28:45 -0700 Subject: [PATCH 11/11] add tests and tweak --- hvplot/tests/testutil.py | 252 +++++++++++++++++++++++++++++++++++++++ hvplot/util.py | 33 +++-- 2 files changed, 268 insertions(+), 17 deletions(-) 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 0270e1a66..e4abe1a99 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -422,14 +422,14 @@ def is_within_latlon_bounds(data: pd.DataFrame | dict, x: str, y: str) -> bool: 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 - 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 - def convert_latlon_to_mercator(lon: np.ndarray, lat: np.ndarray): """Convert lon/lat values to Web Mercator easting/northing.""" @@ -456,26 +456,25 @@ def _bounds_in_range(v0, v1, min_val, 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 - except Exception as e: - warnings.warn(f'Could not unpack limit {limit}: {e}') - return 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)) + 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) + 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):