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

Expose setting hard navigable bounds #6056

Merged
merged 24 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
72 changes: 72 additions & 0 deletions examples/user_guide/Plotting_with_Bokeh.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,78 @@
" img.options(data_aspect=2, frame_width=300).relabel('data_aspect=2')).cols(2)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Navigable Bounds\n",
"\n",
"Users may set the `apply_hard_bounds` option to constrain the navigable range (extent one could zoom or pan to). If `True`, the navigable bounds of the plot will be constrained to the range of the data. Go ahead and try to zoom in a out in the plot below, you should find that you cannot zoom beyond the extents of the data.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"x_values = np.linspace(0, 10, 100)\n",
"y_values = np.sin(x_values)\n",
"\n",
"hv.Curve((x_values, y_values)).opts(apply_hard_bounds=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If `xlim` or `ylim` is set for an element, the navigable bounds of the plot will be set based\n",
"on the combined extremes of extents between the data and xlim/ylim ranges. In the plot below, the `xlim` constrains the initial view, but you should be able to pan to the x-range between 0 and 12 - the combined extremes of ranges between the data (0,10) and `xlim` (2,12)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"x_values = np.linspace(0, 10, 100)\n",
"y_values = np.sin(x_values)\n",
"\n",
"hv.Curve((x_values, y_values)).opts(\n",
" apply_hard_bounds=True,\n",
" xlim=(2, 12),\n",
" )"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"If a dimension range is specified (e.g. with `.redim.range`), this range will be used as the hard bounds, regardless of the data range or xlim/ylim. This is because the dimension range is itended to be an override on the minimum and maximum allowable values for the dimension. Read more in [Annotating your Data](./01-Annotating_Data.ipynb)."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"x_values = np.linspace(0, 10, 100)\n",
"y_values = np.sin(x_values)\n",
"\n",
"hv.Curve((x_values, y_values)).opts(\n",
" apply_hard_bounds=True,\n",
").redim.range(x=(4, 6))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In the plot above, you should not be able to navigate beyond the specified dimension ranges of `x` (4, 6). "
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
35 changes: 35 additions & 0 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ class ElementPlot(BokehPlot, GenericElementPlot):
align = param.ObjectSelector(default='start', objects=['start', 'center', 'end'], doc="""
Alignment (vertical or horizontal) of the plot in a layout.""")

apply_hard_bounds = param.Boolean(default=False, doc="""
If True, the navigable bounds of the plot will be set based
on the more extreme of extents between the data or xlim/ylim ranges.
If dim ranges are set, the hard bounds will be set to the dim ranges.""")

autorange = param.ObjectSelector(default=None, objects=['x', 'y', None], doc="""
Whether to auto-range along either the x- or y-axis, i.e.
when panning or zooming along the orthogonal axis it will
Expand Down Expand Up @@ -1894,6 +1899,10 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
if self._subcoord_overlaid:
if style_element.label in plot.extra_y_ranges:
self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label)

if self.apply_hard_bounds:
self._apply_hard_bounds(element, ranges)

self.handles['plot'] = plot

if self.autorange:
Expand All @@ -1919,6 +1928,29 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):

return plot

def _apply_hard_bounds(self, element, ranges):
"""
Apply hard bounds to the x and y ranges of the plot. If xlim/ylim is set, limit the
initial viewable range to xlim/ylim, but allow navigation up to the abs max between
the data range and xlim/ylim. If dim range is set (e.g. via redim.range), enforce
as hard bounds.

"""

def validate_bound(bound):
return bound if util.isfinite(bound) else None

min_extent_x, min_extent_y, max_extent_x, max_extent_y = map(
validate_bound, self.get_extents(element, ranges, range_type='combined', lims_as_soft_ranges=True)
)

def set_bounds(axis, min_extent, max_extent):
"""Set the bounds for a given axis, using None if both extents are None or identical"""
self.handles[axis].bounds = None if min_extent == max_extent else (min_extent, max_extent)

set_bounds('x_range', min_extent_x, max_extent_x)
set_bounds('y_range', min_extent_y, max_extent_y)
droumis marked this conversation as resolved.
Show resolved Hide resolved

def _setup_data_callbacks(self, plot):
if not self._js_on_data_callbacks:
return
Expand Down Expand Up @@ -2044,6 +2076,9 @@ def update_frame(self, key, ranges=None, plot=None, element=None):
cds = self.handles['cds']
self._postprocess_hover(renderer, cds)

if self.apply_hard_bounds:
self._apply_hard_bounds(element, ranges)

self._update_glyphs(element, ranges, self.style[self.cyclic_index])
self._execute_hooks(element)

Expand Down
19 changes: 16 additions & 3 deletions holoviews/plotting/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -1423,7 +1423,7 @@ def _get_range_extents(self, element, ranges, range_type, xdim, ydim, zdim):

return (x0, y0, x1, y1)

def get_extents(self, element, ranges, range_type='combined', dimension=None, xdim=None, ydim=None, zdim=None, **kwargs):
def get_extents(self, element, ranges, range_type='combined', dimension=None, xdim=None, ydim=None, zdim=None, lims_as_soft_ranges=False, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

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

Are there other inherited classes that need lims_as_soft_ranges?

Copy link
Member Author

Choose a reason for hiding this comment

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

hmm are you hinting at the get_extents method of the class GenericOverlayPlot?

It doesn't appear to be necessary because even for overlay plots, GenericElementPlot's version of get_extents with lims_as_soft_ranges set to True seems to be called anyway. But honestly it's all pretty confusing and get_extents gets called numerous times so I could be missing something.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not hinting at anything specific; just that other classes could also need to implement lims_as_soft_ranges if they don't have the super. But I think we should wait and see if there is a demand for it, before doing anything.

"""
Gets the extents for the axes from the current Element. The globally
computed ranges can optionally override the extents.
Expand All @@ -1444,6 +1444,12 @@ def get_extents(self, element, ranges, range_type='combined', dimension=None, xd

This allows Overlay plots to obtain each range and combine them
appropriately for all the objects in the overlay.

If lims_as_soft_ranges is set to True, the xlim and ylim will be treated as
soft ranges instead of the default case as hard ranges while computing the extents.
This is used e.g. when apply_hard_bounds is True and xlim/ylim is set, in which
case we limit the initial viewable range to xlim/ylim, but allow navigation up to
the abs max between the data range and xlim/ylim.
"""
num = 6 if (isinstance(self.projection, str) and self.projection == '3d') else 4
if self.apply_extents and range_type in ('combined', 'extents'):
Expand Down Expand Up @@ -1486,8 +1492,15 @@ def get_extents(self, element, ranges, range_type='combined', dimension=None, xd
else:
x0, y0, x1, y1 = combined

x0, x1 = util.dimension_range(x0, x1, self.xlim, (None, None))
y0, y1 = util.dimension_range(y0, y1, self.ylim, (None, None))
if lims_as_soft_ranges:
# run x|ylim through max_range to ensure datetime-dtype matching with ranges
xlim_soft_ranges = util.max_range([self.xlim])
ylim_soft_ranges = util.max_range([self.ylim])
x0, x1 = util.dimension_range(x0, x1, (None, None), xlim_soft_ranges)
y0, y1 = util.dimension_range(y0, y1, (None, None), ylim_soft_ranges)
else:
x0, x1 = util.dimension_range(x0, x1, self.xlim, (None, None))
y0, y1 = util.dimension_range(y0, y1, self.ylim, (None, None))

if not self.drawn:
x_range, y_range = ((y0, y1), (x0, x1)) if self.invert_axes else ((x0, x1), (y0, y1))
Expand Down
97 changes: 96 additions & 1 deletion holoviews/tests/plotting/bokeh/test_elementplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import numpy as np
import panel as pn
import param
import pytest
from bokeh.document import Document
from bokeh.models import (
Expand All @@ -16,7 +17,8 @@
tools,
)

from holoviews.core import DynamicMap, HoloMap, NdOverlay
from holoviews import opts
from holoviews.core import DynamicMap, HoloMap, NdOverlay, Overlay
from holoviews.core.util import dt_to_int
from holoviews.element import Curve, HeatMap, Image, Labels, Scatter
from holoviews.plotting.util import process_cmap
Expand Down Expand Up @@ -993,3 +995,96 @@ def test_clim_percentile(self):
low, high = plot.ranges[('Image',)]['z']['robust']
assert low > 0
assert high < 1

class TestApplyHardBounds(TestBokehPlot):
def test_apply_hard_bounds(self):
"""Test `apply_hard_bounds` with a single element."""
x_values = np.linspace(10, 50, 5)
y_values = np.array([10, 20, 30, 40, 50])
curve = Curve((x_values, y_values)).opts(apply_hard_bounds=True)
plot = bokeh_renderer.get_plot(curve)
assert plot.handles['x_range'].bounds == (10, 50)

def test_apply_hard_bounds_overlay(self):
"""Test `apply_hard_bounds` with an overlay of curves."""
x1_values = np.linspace(10, 50, 5)
x2_values = np.linspace(10, 90, 5)
y_values = np.array([10, 20, 30, 40, 50])
curve1 = Curve((x1_values, y_values))
curve2 = Curve((x2_values, y_values))
overlay = Overlay([curve1, curve2]).opts(opts.Curve(apply_hard_bounds=True))
plot = bokeh_renderer.get_plot(overlay)
# Check if the large of the data range can be navigated to
assert plot.handles['x_range'].bounds == (10, 90)

def test_apply_hard_bounds_with_xlim(self):
"""Test `apply_hard_bounds` with `xlim` set. Initial view should be within xlim but allow panning to data range."""
x_values = np.linspace(10, 50, 5)
y_values = np.array([10, 20, 30, 40, 50])
curve = Curve((x_values, y_values)).opts(apply_hard_bounds=True, xlim=(15, 35))
plot = bokeh_renderer.get_plot(curve)
initial_view_range = (plot.handles['x_range'].start, plot.handles['x_range'].end)
assert initial_view_range == (15, 35)
# Check if data beyond xlim can be navigated to
assert plot.handles['x_range'].bounds == (10, 50)

def test_apply_hard_bounds_with_redim_range(self):
"""Test `apply_hard_bounds` with `.redim.range(x=...)`. Hard bounds should strictly apply."""
x_values = np.linspace(10, 50, 5)
y_values = np.array([10, 20, 30, 40, 50])
curve = Curve((x_values, y_values)).redim.range(x=(25, None)).opts(apply_hard_bounds=True)
plot = bokeh_renderer.get_plot(curve)
# Expected to strictly adhere to any redim.range bounds, otherwise the data range
assert (plot.handles['x_range'].start, plot.handles['x_range'].end) == (25, 50)
assert plot.handles['x_range'].bounds == (25, 50)

def test_apply_hard_bounds_datetime(self):
"""Test datetime axes with hard bounds."""
target_xlim_l = dt.datetime(2020, 1, 3)
target_xlim_h = dt.datetime(2020, 1, 7)
dates = [dt.datetime(2020, 1, i) for i in range(1, 11)]
values = np.linspace(0, 100, 10)
curve = Curve((dates, values)).opts(
apply_hard_bounds=True,
xlim=(target_xlim_l, target_xlim_h)
)
plot = bokeh_renderer.get_plot(curve)
initial_view_range = (dt_to_int(plot.handles['x_range'].start), dt_to_int(plot.handles['x_range'].end))
assert initial_view_range == (dt_to_int(target_xlim_l), dt_to_int(target_xlim_h))
# Validate navigation bounds include entire data range
hard_bounds = (dt_to_int(plot.handles['x_range'].bounds[0]), dt_to_int(plot.handles['x_range'].bounds[1]))
assert hard_bounds == (dt_to_int(dt.datetime(2020, 1, 1)), dt_to_int(dt.datetime(2020, 1, 10)))

def test_dynamic_map_bounds_update(self):
"""Test that `apply_hard_bounds` applies correctly when DynamicMap is updated."""

def curve_data(choice):
datasets = {
'set1': (np.linspace(0, 5, 100), np.random.rand(100)),
'set2': (np.linspace(0, 20, 100), np.random.rand(100)),
}
x, y = datasets[choice]
return Curve((x, y))

ChoiceStream = Stream.define(
'Choice',
choice=param.ObjectSelector(default='set1', objects=['set1', 'set2'])
)
choice_stream = ChoiceStream()
dmap = DynamicMap(curve_data, kdims=[], streams=[choice_stream])
dmap = dmap.opts(opts.Curve(apply_hard_bounds=True, xlim=(2,3), framewise=True))
dmap = dmap.redim.values(choice=['set1', 'set2'])
plot = bokeh_renderer.get_plot(dmap)

# Keeping the xlim consistent between updates, and change data range bounds
# Initially select 'set1'
dmap.event(choice='set1')
assert plot.handles['x_range'].start == 2
assert plot.handles['x_range'].end == 3
assert plot.handles['x_range'].bounds == (0, 5)

# Update to 'set2'
dmap.event(choice='set2')
assert plot.handles['x_range'].start == 2
assert plot.handles['x_range'].end == 3
assert plot.handles['x_range'].bounds == (0, 20)
Loading