Skip to content
4 changes: 3 additions & 1 deletion doc/ref/plotting_options/axis.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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`](option-tiles) is provided and the `xlim` and `ylim` are in `lat`/`lon` coordinates, they will be automatically transformed to Web Mercator coordinates."
]
},
{
Expand Down
31 changes: 31 additions & 0 deletions doc/ref/plotting_options/geographic.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 25 additions & 21 deletions hvplot/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +82,9 @@
import_geoviews,
is_mpl_cmap,
_find_stack_level,
is_within_latlon_bounds,
convert_latlon_to_mercator,
convert_limit_to_mercator,
)
from .utilities import hvplot_extension

Expand Down Expand Up @@ -1003,6 +1006,17 @@ 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):
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]):
Expand Down Expand Up @@ -2529,26 +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)
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 = data.copy()
easting, northing = convert_latlon_to_mercator(data[x], data[y])
new_x = 'x' if 'x' not in data else 'x_'
Copy link
Member

Choose a reason for hiding this comment

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

While we're at it, maybe we can do better than that here with a small utility function that finds a field name not already used (e.g. appending underscores until it finds it's available). In this case we don't check if 'x_' is already used or not.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Not too convinced we need this; I personally have not encountered coordinates with x_ in my experience.

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):
Expand Down
105 changes: 105 additions & 0 deletions hvplot/tests/testgeowithoutgv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -104,3 +128,84 @@ 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"""
df = pd.DataFrame(
Copy link
Member

Choose a reason for hiding this comment

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

I suggest using a fixture instead of repeating in each function the same DataFrame definition.

{'lon': [-120.0, -100.0, -80.0], 'lat': [30.0, 35.0, 40.0], 'value': [1, 2, 3]}
)
plot = df.hvplot.points('lon', 'lat', tiles=True, xlim=(-130, -70), ylim=(25, 45))
points = plot.get(1)

assert 'x' in points.data.columns
assert 'y' in points.data.columns

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)

bk_plot = bk_renderer.get_plot(plot)
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

def test_xlim_only_conversion_with_tiles(self, lat_lon_df):
"""xlim should convert even when ylim is not provided."""

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

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, lat_lon_df):
"""ylim should convert even when xlim is not provided."""
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
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, lat_lon_df):
"""Test that xlim and ylim are NOT converted when tiles=False"""
plot = lat_lon_df.hvplot.points('lon', 'lat', xlim=(-130, -70), ylim=(25, 45))
bk_plot = bk_renderer.get_plot(plot)

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 == -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, mercator_df):
"""Test that xlim and ylim are NOT converted when values are outside lat/lon bounds"""
plot = mercator_df.hvplot.points('x', 'y', tiles=True, xlim=(1000, 3000), ylim=(400, 800))
bk_plot = bk_renderer.get_plot(plot.get(1))

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 == 1000
assert x_range_end == 3000
assert y_range_start == 400
assert y_range_end == 800
Loading
Loading