Skip to content

Commit

Permalink
Automatically convert lonlat to xy when tiles=True (#1377)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored Sep 13, 2024
1 parent 8dd07d8 commit 44ed4ed
Show file tree
Hide file tree
Showing 6 changed files with 82 additions and 17 deletions.
2 changes: 1 addition & 1 deletion doc/user_guide/Customization.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,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",
" - `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 @@ -323,7 +323,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 @@ -686,6 +687,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 @@ -2138,6 +2142,40 @@ def _process_chart_args(self, data, x, y, single_y=False, categories=None):
post_not_found, data = process_derived_datetime_pandas(data, not_found, self.indexes)
self.variables.extend(set(post_not_found) - set(not_found))

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):
Expand Down Expand Up @@ -2667,6 +2705,7 @@ def _process_gridded_args(self, data, x, y, z):
post_not_found, data = process_derived_datetime_pandas(data, not_found, self.indexes)
self.variables.extend([item for item in not_found if item not in post_not_found])

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

0 comments on commit 44ed4ed

Please sign in to comment.