Bidirectional interlinks with @param.depends

Hello,

I am trying to create an app with interlinked widgets as a Parameterized class.
The idea would be to have two date pickers (start date and end date) and a slider visualizing the difference in days.
The user should be able to move back the start date by using either the slider or the date picker, and the other widget should update accordingly.

I have been able to (more or less) achieve the desired behavior using simple links like this:

import panel as pn
import param
from datetime import datetime, timedelta

pn.extension()

_today = datetime.today().date()
_startdate = _today - timedelta(days=1)
_interval = (_today - _startdate).days

startdate_slider = pn.widgets.IntSlider(name='Interval', start=1, end=100, step=1, value=_interval)
startdate_dp = pn.widgets.DatePicker(name='Start date', value = _today - timedelta(days=_interval))
enddate_dp = pn.widgets.DatePicker(name='End date', value=_today)

# Linking DatePicker and slider
def intervalToDate(dp, e):
    dp.value = _today - timedelta(days=e.new)
    
def dateToInterval(slider, e):
    slider.value = (_today - e.new).days
    
startdate_slider.link(startdate_dp, callbacks={'value': intervalToDate})
startdate_dp.link(startdate_slider, callbacks={'value': dateToInterval})

pn.Column(startdate_slider, startdate_dp, enddate_dp)

I would like to have the same behaviour, but in a Parameterized class using @param.depends.

At first, I came up with the code below, which not only doesn’t work, but even crashes the kernel:

import panel as pn
import param
from datetime import datetime, timedelta

pn.extension()

class DateInput(param.Parameterized):
    
    _DATE_BOUNDS = (datetime(2010, 1, 1).date(), datetime.today().date())
    
    _today = datetime.today().date()
    _startdate = _today - timedelta(days=1)
    _interval = (_today - _startdate).days
    
    interval = param.Integer(default=_interval, bounds=(1, 100))
    start_date = param.Date(default=_startdate, bounds=_DATE_BOUNDS)
    end_date = param.Date(default=_today, bounds=_DATE_BOUNDS)
    
    mapping = {'start_date': {'type': pn.widgets.DatePicker},                                        
               'end_date'  : {'type': pn.widgets.DatePicker}}
    
    @param.depends('interval', 'start_date', 'end_date', watch=True)
    def _link_dates(self):
        self.interval = (self.end_date - self.start_date).days            
        self.start_date  = self.param.start_date.crop_to_bounds(self.end_date - timedelta(days=self.interval))
        
app = DateInput()

pn.Param(app.param, widgets=app.mapping, show_name=False, default_layout=pn.Column)

Suspecting the crash was caused by an “infinite loop” (_update_link triggering itself) I tried to split the updating logic into two different functions like this:

import panel as pn
import param
from datetime import datetime, timedelta

pn.extension()

class DateInput(param.Parameterized):
    
    _DATE_BOUNDS = (datetime(2010, 1, 1).date(), datetime.today().date())
    
    _today = datetime.today().date()
    _startdate = _today - timedelta(days=1)
    _interval = (_today - _startdate).days
    
    interval = param.Integer(default=_interval, softbounds=(1, 100))
    start_date = param.Date(default=_startdate, bounds=_DATE_BOUNDS)
    end_date = param.Date(default=_today, bounds=_DATE_BOUNDS)
    
    mapping = {'start_date': {'type': pn.widgets.DatePicker},                                        
               'end_date'  : {'type': pn.widgets.DatePicker}}
    
    @param.depends('start_date', 'end_date', watch=True)
    def _update_interval(self):
        print('_update_interval is running with start_date = {} and end_date = {}'.format(self.start_date, self.end_date))
        self.interval = (self.end_date - self.start_date).days
        print('_update_interval ran leaving self.interval = {}'.format(self.interval))
            
    @param.depends('interval', watch=True)      
    def _update_date(self):
        print('_update_date is running with interval = {}'.format(self.interval))
        self.start_date  = self.param.start_date.crop_to_bounds(self.end_date - timedelta(days=self.interval))
        print('_update_date ran leaving start_date = {}'.format(self.start_date))
        
app = DateInput()

pn.Param(app.param, widgets=app.mapping, show_name=False, default_layout=pn.Column)

Running this, every time I touch the slider or a date picker, I can see that each _update function is activated twice.
However, each _update function seems to be triggering the other one before it actually changes the value, so the linking doesn’t really work.

What would be the “cleanest” way to achieve the desired behavior in a Parameterized class?

Answering this myself in case someone runs into a similar issue:
there was nothing wrong with the depends in the second version of the code: the issue was crop_to_bounds not working on dates and resetting to the default value, as described in this bug.

1 Like