diff --git a/geoviews/plotting/bokeh/callbacks.py b/geoviews/plotting/bokeh/callbacks.py index a1bdab56..1ec24844 100644 --- a/geoviews/plotting/bokeh/callbacks.py +++ b/geoviews/plotting/bokeh/callbacks.py @@ -96,8 +96,8 @@ def project_ranges(cb, msg, attributes): extents = x0, y0, x1, y1 x0, y0, x1, y1 = project_extents(extents, plot.projection, plot.current_frame.crs) - if plot._unwrap_lons and -180 <= x0 < 0 or -180 <= x1 < 0: - x0, x1 = x0 + 360, x1 + 360 + if plot._unwrap_lons and (-180 <= x0 < 0 or -180 <= x1 < 0): + x1 += 360 if x0 > x1: x0, x1 = x1, x0 coords = {'x_range': (x0, x1), 'y_range': (y0, y1)} diff --git a/geoviews/plotting/bokeh/plot.py b/geoviews/plotting/bokeh/plot.py index 20d1a118..d44017d0 100644 --- a/geoviews/plotting/bokeh/plot.py +++ b/geoviews/plotting/bokeh/plot.py @@ -6,7 +6,7 @@ from bokeh.models.tools import BoxZoomTool, WheelZoomTool from cartopy.crs import GOOGLE_MERCATOR, Mercator, PlateCarree, _CylindricalProjection from holoviews.core.dimension import Dimension -from holoviews.core.util import dimension_sanitizer +from holoviews.core.util import dimension_sanitizer, match_spec from holoviews.plotting.bokeh.element import ElementPlot, OverlayPlot as HvOverlayPlot from ...element import Shape, _Element, is_geographic @@ -104,14 +104,27 @@ def _update_ranges(self, element, ranges): ax_range.end = mid + min_interval/2. ax_range.min_interval = min_interval - def _set_unwrap_lons(self, element): + def _set_unwrap_lons(self, element, ranges): + """ + Check whether the lons should be transformed from 0, 360 to -180, 180 + """ if isinstance(self.geographic, _CylindricalProjection): - x1, x2 = element.range(0) - self._unwrap_lons = 0 <= x1 <= 360 and 0 <= x2 <= 360 + xdim = element.get_dimension(0) + x_range = ranges.get(xdim.name, {}).get('data') + if x_range: + x0, x1 = x_range + else: + x0, x1 = element.range(0) + # x0, depending on the step/interval, can be slightly less than 0, + # e.g. lon=np.arange(0, 360, 10) -> x0 = -5 from (step 10 / 2) + # other projections likely will not fall within this range + self._unwrap_lons = -90 <= x0 <= 360 and 180 <= x1 <= 540 def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): opts = {} if isinstance(self, HvOverlayPlot) else {'source': source} fig = super().initialize_plot(ranges, plot, plots, **opts) + style_element = self.current_frame.last if self.batched else self.current_frame + el_ranges = match_spec(style_element, self.current_ranges) if self.current_ranges else {} if self.geographic and self.show_bounds and not self.overlaid: from . import GeoShapePlot shape = Shape(self.projection.boundary, crs=self.projection).options(fill_alpha=0) @@ -119,13 +132,14 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None): overlaid=True, renderer=self.renderer) shapeplot.geographic = False shapeplot.initialize_plot(plot=fig) - self._set_unwrap_lons(self.current_frame) + self._set_unwrap_lons(style_element, el_ranges) return fig def update_frame(self, key, ranges=None, element=None): - if element is not None: - self._set_unwrap_lons(element) super().update_frame(key, ranges=ranges, element=element) + style_element = self.current_frame.last if self.batched else self.current_frame + el_ranges = match_spec(style_element, self.current_ranges) if self.current_ranges else {} + self._set_unwrap_lons(style_element, el_ranges) def _postprocess_hover(self, renderer, source): super()._postprocess_hover(renderer, source) diff --git a/geoviews/tests/conftest.py b/geoviews/tests/conftest.py index 65fd24aa..34e20174 100644 --- a/geoviews/tests/conftest.py +++ b/geoviews/tests/conftest.py @@ -1,5 +1,12 @@ from contextlib import suppress +from holoviews.tests.conftest import ( # noqa: F401 + bokeh_backend, + port, + serve_hv, + server_cleanup, +) + import geoviews as gv CUSTOM_MARKS = ("ui",) diff --git a/geoviews/tests/ui/__init__.py b/geoviews/tests/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/geoviews/tests/ui/test_example.py b/geoviews/tests/ui/test_example.py deleted file mode 100644 index e7d4254b..00000000 --- a/geoviews/tests/ui/test_example.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest - -pytestmark = pytest.mark.ui - - -def test_ui_example(page): - assert True diff --git a/geoviews/tests/ui/test_plot.py b/geoviews/tests/ui/test_plot.py new file mode 100644 index 00000000..df9b64ad --- /dev/null +++ b/geoviews/tests/ui/test_plot.py @@ -0,0 +1,81 @@ +import cartopy.crs as ccrs +import holoviews as hv +import numpy as np +import pytest +from holoviews.tests.ui import expect, wait_until + +import geoviews as gv + +pytestmark = pytest.mark.ui + +xr = pytest.importorskip("xarray") + + +@pytest.mark.usefixtures("bokeh_backend") +def test_range_correct_longitude(serve_hv): + """ + Regression test for https://github.com/holoviz/geoviews/issues/753 + """ + coastline = gv.feature.coastline().opts(active_tools=["box_zoom"]) + xy_range = hv.streams.RangeXY(source=coastline) + + page = serve_hv(coastline) + hv_plot = page.locator(".bk-events") + + expect(hv_plot).to_have_count(1) + + bbox = hv_plot.bounding_box() + hv_plot.click() + + page.mouse.move(bbox["x"] + 100, bbox["y"] + 100) + page.mouse.down() + page.mouse.move(bbox["x"] + 150, bbox["y"] + 150, steps=5) + page.mouse.up() + + wait_until(lambda: np.isclose(xy_range.x_range[0], -105.68691588784145), page) + wait_until(lambda: np.isclose(xy_range.x_range[1], -21.80841121496224), page) + + +@pytest.mark.usefixtures("bokeh_backend") +@pytest.mark.parametrize("lon_start,lon_end", [(-180, 180), (0, 360)]) +@pytest.mark.parametrize("bbox_x", [100, 250]) +def test_rasterize_with_coastline_not_blank_on_zoom(serve_hv, lon_start, lon_end, bbox_x): + """ + Regression test for https://github.com/holoviz/geoviews/issues/726 + """ + from holoviews.operation.datashader import rasterize + + lon = np.linspace(lon_start, lon_end, 360) + lat = np.linspace(-90, 90, 180) + data = np.random.rand(180, 360) + ds = xr.Dataset({"data": (["lat", "lon"], data)}, coords={"lon": lon, "lat": lat}) + + overlay = rasterize( + gv.Image(ds, ["lon", "lat"], ["data"], crs=ccrs.PlateCarree()).opts( + tools=["hover"], active_tools=["box_zoom"] + ) + ) * gv.feature.coastline() + + page = serve_hv(overlay) + + hv_plot = page.locator(".bk-events") + + expect(hv_plot).to_have_count(1) + + bbox = hv_plot.bounding_box() + hv_plot.click() + + page.mouse.move(bbox["x"] + bbox_x, bbox["y"] + 100) + page.mouse.down() + page.mouse.move(bbox["x"] + bbox_x + 50, bbox["y"] + 150, steps=5) + page.mouse.up() + + # get hover tooltip + page.mouse.move(bbox["x"] + 100, bbox["y"] + 150) + + wait_until(lambda: expect(page.locator(".bk-Tooltip")).to_have_count(1), page=page) + + expect(page.locator(".bk-Tooltip")).to_contain_text("lon:") + expect(page.locator(".bk-Tooltip")).to_contain_text("lat:") + expect(page.locator(".bk-Tooltip")).to_contain_text("data:") + expect(page.locator(".bk-Tooltip")).not_to_contain_text("?") diff --git a/pixi.toml b/pixi.toml index 744fb6e9..a8e497ea 100644 --- a/pixi.toml +++ b/pixi.toml @@ -16,7 +16,7 @@ test-310 = ["py310", "test-core", "test-unit-task", "test", "example", "test-exa test-311 = ["py311", "test-core", "test-unit-task", "test", "example", "test-example", "download-data"] test-312 = ["py312", "test-core", "test-unit-task", "test", "example", "test-example", "download-data"] test-core = ["py312", "test-unit-task", "test-core"] -test-ui = ["py312", "test-core", "test", "test-ui"] +test-ui = ["py312", "test-core", "test", "test-ui", "download-data"] docs = ["py311", "example", "doc", "download-data"] build = ["py311", "build"] lint = ["py311", "lint"] diff --git a/scripts/download_data.py b/scripts/download_data.py index 77511056..c758fb16 100644 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -28,3 +28,8 @@ xr.tutorial.open_dataset("air_temperature") xr.tutorial.open_dataset("rasm") + +with suppress(ImportError): + from cartopy.feature import shapereader + + shapereader.natural_earth()