Skip to content

Commit

Permalink
Set hard navigable bounds (#6056)
Browse files Browse the repository at this point in the history
Co-authored-by: Philipp Rudiger <prudiger@anaconda.com>
Co-authored-by: Simon Høxbro Hansen <simon.hansen@me.com>
  • Loading branch information
3 people authored Apr 16, 2024
1 parent 97d648b commit ffb1293
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 4 deletions.
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
38 changes: 38 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 @@ -2034,6 +2039,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 @@ -2059,6 +2068,32 @@ 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"""
try:
self.handles[axis].bounds = None if min_extent == max_extent else (min_extent, max_extent)
except ValueError:
self.handles[axis].bounds = None

set_bounds('x_range', min_extent_x, max_extent_x)
set_bounds('y_range', min_extent_y, max_extent_y)

def _setup_data_callbacks(self, plot):
if not self._js_on_data_callbacks:
return
Expand Down Expand Up @@ -2184,6 +2219,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):
"""
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)

0 comments on commit ffb1293

Please sign in to comment.