Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically convert lonlat to xy when tiles=True #1377

Merged
merged 9 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/user_guide/Customization.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 16 additions & 14 deletions doc/user_guide/Geographic_Data.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
]
Expand All @@ -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)."
]
},
{
Expand All @@ -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."
]
},
{
Expand All @@ -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`."
]
},
{
Expand All @@ -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)"
]
},
{
Expand Down Expand Up @@ -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",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on removing this cell and only link directly to Customization geographic section to reduce redundancy?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to keep them here for now, I think it's nice to have all the options there within the appropriate context.

" - `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",
Expand Down
43 changes: 41 additions & 2 deletions hvplot/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]):
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

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):
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions hvplot/tests/testgeo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions hvplot/tests/testgeowithoutgv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions hvplot/tests/testgridplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading