diff --git a/doc/user_guide/Customization.ipynb b/doc/user_guide/Customization.ipynb index 4f99b0b6f..8a097c19c 100644 --- a/doc/user_guide/Customization.ipynb +++ b/doc/user_guide/Customization.ipynb @@ -229,7 +229,7 @@ " projection (default=None):\n", " Coordinate reference system of the plot (output projection) specified as a string or integer EPSG code, a CRS or Proj pyproj object, a Cartopy CRS object or class name, a WKT string, or a proj.4 string. Defaults to PlateCarree.\n", " tiles (default=False):\n", - " Whether to overlay the plot on a tile source. Tiles sources\n", + " Whether to overlay the plot on a tile source. If coordinate values fall within lat/lon bounds, auto-projects to EPSG:3857 unless `projection=False`. Tiles sources\n", " can be selected by name or a tiles object or class can be passed,\n", " the default is 'Wikipedia'.\n", " tiles_opts (default=None): dict\n", diff --git a/doc/user_guide/Geographic_Data.ipynb b/doc/user_guide/Geographic_Data.ipynb index 2f2f378a1..7a3a8c70a 100644 --- a/doc/user_guide/Geographic_Data.ipynb +++ b/doc/user_guide/Geographic_Data.ipynb @@ -60,7 +60,7 @@ "source": [ "## Tiled web map\n", "\n", - "A [tiled web map](https://en.wikipedia.org/wiki/Tiled_web_map), or tile map, is an interactive map displayed on a web browser that is divided into small, pre-rendered image tiles, allowing for efficient loading and seamless navigation across various zoom levels and geographic areas. hvPlot allows to add a tile map as a basemap to a plot with the `tiles` parameter. Importantly, `tiles` is a parameter that can be used **without installing GeoViews if the data is already projected in Web Mercator**.\n", + "A [tiled web map](https://en.wikipedia.org/wiki/Tiled_web_map), or tile map, is an interactive map displayed on a web browser that is divided into small, pre-rendered image tiles, allowing for efficient loading and seamless navigation across various zoom levels and geographic areas. hvPlot allows to add a tile map as a basemap to a plot with the `tiles` parameter. Importantly, `tiles` is a parameter that can be used **without installing GeoViews**.\n", "\n", "We'll display this dataframe of all US airports (including military bases overseas), the points are expressed in latitude/longitude coordinates:" ] @@ -78,7 +78,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We'll first start by displaying the airports **without GeoViews**. To do so, we must convert the coordinates to Web Mercator, which can be easily achieved using the `lon_lat_to_easting_northing` function provided by HoloViews, that doesn't require installing any of the usual geo dependencies like `pyproj` or `cartopy`." + "We'll first start by displaying the airports **without GeoViews** with tiles by setting `tiles=True`. \n", + "\n", + "Under the hood, hvPlot projects lat/lon to easting/northing (EPSG:4326 to EPSG:3857) coordinates without additional package dependencies if it detects that the values falls within expected lat/lon ranges.\n", + "\n", + "Note, **this feature is only available after `hvplot>0.10.0`**; older versions, `hvplot<=0.10.0`, require manual projection (see below)." ] }, { @@ -87,17 +91,14 @@ "metadata": {}, "outputs": [], "source": [ - "from holoviews.util.transform import lon_lat_to_easting_northing\n", - "\n", - "airports['x'], airports['y'] = lon_lat_to_easting_northing(airports.Longitude, airports.Latitude)\n", - "airports[['Latitude', 'Longitude', 'x', 'y']].head(3)" + "airports.hvplot.points('Longitude', 'Latitude', tiles=True, color='red', alpha=0.2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We can now easily display the airports on a basemap by setting `tiles` to `True`." + "If you do not want it to be auto-projected under the hood for any reason, set `projection=False`, **but note it will not be properly placed on the tiles** without manual intervention. In doing so, the data will be plotted around the [null island location](https://en.wikipedia.org/wiki/Null_Island), roughly 600km off the coast of West Africa. `padding` was added to demonstrate this." ] }, { @@ -106,14 +107,14 @@ "metadata": {}, "outputs": [], "source": [ - "airports.hvplot.points('x', 'y', tiles=True, color='red', alpha=0.2)" + "airports.hvplot.points('Longitude', 'Latitude', tiles=True, projection=False, color='red', alpha=0.2, padding=10000)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "A common mistake is to display data referenced in WGS84 on a tile basemap without projecting the data to Web Mercator. In doing so, the data will be plotted around the [null island location](https://en.wikipedia.org/wiki/Null_Island), roughly 600km off the coast of West Africa." + "To manually project use the `lon_lat_to_easting_northing` function provided by HoloViews, that doesn't require installing any of the usual geo dependencies like `pyproj` or `cartopy`." ] }, { @@ -122,10 +123,11 @@ "metadata": {}, "outputs": [], "source": [ - "airports.hvplot.points(\n", - " 'Longitude', 'Latitude', tiles=True, color='red', alpha=0.2,\n", - " xlim=(-1000000, 1000000), ylim=(-1000000, 1000000)\n", - ")" + "from holoviews.util.transform import lon_lat_to_easting_northing\n", + "\n", + "airports['x'], airports['y'] = lon_lat_to_easting_northing(airports.Longitude, airports.Latitude)\n", + "airports[['Latitude', 'Longitude', 'x', 'y']].head(3)\n", + "airports.hvplot.points('x', 'y', tiles=True, color='red', alpha=0.2)" ] }, { @@ -415,7 +417,7 @@ "- `global_extent` (default=False): Whether to expand the plot extent to span the whole globe\n", "- `project` (default=False): Whether to project the data before plotting (adds initial overhead but avoids projecting data when plot is dynamically updated)\n", "- `projection` (default=None): Coordinate reference system of the plot (output projection) specified as a string or integer EPSG code, a CRS or Proj pyproj object, a Cartopy CRS object or class name, a WKT string, or a proj.4 string. Defaults to PlateCarree.\n", - "- `tiles` (default=False): Whether to overlay the plot on a tile source. Accept the following values:\n", + "- `tiles` (default=False): Whether to overlay the plot on a tile source. If coordinate values fall within lat/lon bounds, auto-projects to EPSG:3857 unless `projection=False`. Accepts the following values:\n", " - `True`: OpenStreetMap layer\n", " - `xyzservices.TileProvider` instance (requires [`xyzservices`](https://xyzservices.readthedocs.io/) to be installed)\n", " - a map string name based on one of the default layers made available by [HoloViews](https://holoviews.org/reference/elements/bokeh/Tiles.html) ('CartoDark', 'CartoLight', 'EsriImagery', 'EsriNatGeo', 'EsriUSATopo', 'EsriTerrain', 'EsriStreet', 'EsriReference', 'OSM', 'OpenTopoMap') or [GeoViews](https://geoviews.org/user_guide/Working_with_Bokeh.html) ('CartoDark', 'CartoEco', 'CartoLight', 'CartoMidnight', 'EsriImagery', 'EsriNatGeo', 'EsriUSATopo', 'EsriTerrain', 'EsriReference', 'EsriOceanBase', 'EsriOceanReference', 'EsriWorldPhysical', 'EsriWorldShadedRelief', 'EsriWorldTopo', 'EsriWorldDarkGrayBase', 'EsriWorldDarkGrayReference', 'EsriWorldLightGrayBase', 'EsriWorldLightGrayReference', 'EsriWorldHillshadeDark', 'EsriWorldHillshade', 'EsriAntarcticImagery', 'EsriArcticImagery', 'EsriArcticOceanBase', 'EsriArcticOceanReference', 'EsriWorldBoundariesAndPlaces', 'EsriWorldBoundariesAndPlacesAlternate', 'EsriWorldTransportation', 'EsriDelormeWorldBaseMap', 'EsriWorldNavigationCharts', 'EsriWorldStreetMap', 'OSM', 'OpenTopoMap'). Note that Stamen tile sources require a Stadia account when not running locally; see [stadiamaps.com](https://stadiamaps.com/).\n", diff --git a/hvplot/converter.py b/hvplot/converter.py index 25c624b6f..07b1785cf 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -46,7 +46,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 +from holoviews.util.transform import dim, lon_lat_to_easting_northing from pandas import DatetimeIndex, MultiIndex from .backend_transforms import _transfer_opts_cur_backend @@ -309,7 +309,8 @@ class HoloViewsConverter: CRS object or class name, a WKT string, or a proj.4 string. Defaults to PlateCarree. tiles (default=False): - Whether to overlay the plot on a tile source: + Whether to overlay the plot on a tile source. If coordinate values fall within + lat/lon bounds, auto-projects to EPSG:3857, unless `projection=False`. - `True`: OpenStreetMap layer - `xyzservices.TileProvider` instance (requires xyzservices to be installed) @@ -667,6 +668,9 @@ def __init__( xlim = (x0, x1) if ylim: ylim = (y0, y1) + elif projection is False: + # to disable automatic projection of tiles + self.output_projection = projection # Operations if resample_when is not None and not any([rasterize, datashade, downsample]): @@ -2092,6 +2096,40 @@ def _process_chart_args(self, data, x, y, single_y=False, categories=None): not_found = [dim for dim in dimensions if dim not in self.variables] _, data = process_derived_datetime_pandas(data, not_found, self.indexes) + data, x, y = self._process_tiles_without_geo(data, x, y) + return data, x, y + + def _process_tiles_without_geo(self, data, x, y): + """ + Tiles without requiring geoviews/cartopy. + """ + if self.geo or not self.tiles or self.output_projection is False: + return data, x, y + elif not is_geodataframe(data) and (x is None or y is None): + return data, x, y + + if is_geodataframe(data): + if data.crs is not None: + data = data.to_crs(epsg=3857) + return data, x, y + 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}) + return data, new_x, new_y return data, x, y def chart(self, element, x, y, data=None): @@ -2622,6 +2660,7 @@ def _process_gridded_args(self, data, x, y, z): not_found = [dim for dim in dimensions if dim not in self.variables] _, data = process_derived_datetime_pandas(data, not_found, self.indexes) + data, x, y = self._process_tiles_without_geo(data, x, y) return data, x, y, z def _get_element(self, kind): diff --git a/hvplot/tests/testgeo.py b/hvplot/tests/testgeo.py index 0ed617fa1..24930aad5 100644 --- a/hvplot/tests/testgeo.py +++ b/hvplot/tests/testgeo.py @@ -443,6 +443,15 @@ def test_geometry_none(self): polygons.geometry[1] = None assert polygons.hvplot(geo=True) + def test_tiles_without_gv(self): + polygons = self.polygons.copy() + polygons_plot = polygons.hvplot(tiles=True) + polygons_plot.get(1).data.crs is None + + polygons.crs = 'EPSG:4326' + polygons_plot = self.polygons.hvplot(tiles=True) + polygons_plot.get(1).data.crs == 'EPSG:3857' + class TestGeoUtil(TestCase): def setUp(self): diff --git a/hvplot/tests/testgeowithoutgv.py b/hvplot/tests/testgeowithoutgv.py index 97430fd96..de1d90159 100644 --- a/hvplot/tests/testgeowithoutgv.py +++ b/hvplot/tests/testgeowithoutgv.py @@ -23,6 +23,8 @@ def test_plot_tiles_doesnt_set_geo(self, simple_df): assert len(plot) == 2 assert isinstance(plot.get(0), hv.Tiles) assert 'openstreetmap' in plot.get(0).data + assert 'x_' in plot.get(1).data + assert 'y_' in plot.get(1).data bk_plot = bk_renderer.get_plot(plot) assert bk_plot.projection == 'mercator' @@ -31,6 +33,8 @@ def test_plot_specific_tiles_doesnt_set_geo(self, simple_df): assert len(plot) == 2 assert isinstance(plot.get(0), hv.Tiles) assert 'ArcGIS' in plot.get(0).data + assert 'x_' in plot.get(1).data + assert 'y_' in plot.get(1).data bk_plot = bk_renderer.get_plot(plot) assert bk_plot.projection == 'mercator' @@ -47,12 +51,16 @@ def test_plot_with_specific_tile_obj(self, simple_df): assert len(plot) == 2 assert isinstance(plot.get(0), hv.Tiles) assert 'ArcGIS' in plot.get(0).data + assert 'x_' in plot.get(1).data + assert 'y_' in plot.get(1).data bk_plot = bk_renderer.get_plot(plot) assert bk_plot.projection == 'mercator' def test_plot_with_xyzservices_tileprovider(self, simple_df): xyzservices = pytest.importorskip('xyzservices') plot = simple_df.hvplot.points('x', 'y', tiles=xyzservices.providers.Esri.WorldImagery) + assert 'x_' in plot.get(1).data + assert 'y_' in plot.get(1).data assert len(plot) == 2 assert isinstance(plot.get(0), hv.Tiles) assert isinstance(plot.get(0).data, xyzservices.TileProvider) diff --git a/hvplot/tests/testgridplots.py b/hvplot/tests/testgridplots.py index 1a90b1ed8..2ab063eb5 100644 --- a/hvplot/tests/testgridplots.py +++ b/hvplot/tests/testgridplots.py @@ -257,3 +257,10 @@ def test_dataarray_label_precedence(self): plot = self.da_rgb.sel(band=1).hvplot.image(label='b', value_label='c') assert plot.vdims[0].name == 'b' + + def test_tiles_without_gv(self): + plot = self.ds.hvplot('lon', 'lat', tiles=True) + assert len(plot) == 2 + assert isinstance(plot.get(1), Image) + assert 'x' in plot.get(1).data + assert 'y' in plot.get(1).data