Skip to content

Commit

Permalink
Add DatetimeSlider widget (#7374)
Browse files Browse the repository at this point in the history
  • Loading branch information
thuydotm authored Oct 31, 2024
1 parent dc2fce3 commit f712587
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 6 deletions.
102 changes: 102 additions & 0 deletions examples/reference/widgets/DatetimeSlider.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import datetime as dt\n",
"import panel as pn\n",
"\n",
"pn.extension()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``DatetimeSlider`` widget allows selecting a datetime value within a set bounds using a slider.\n",
"\n",
"Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.md).\n",
"\n",
"#### Parameters:\n",
"\n",
"For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n",
"\n",
"##### Core\n",
"\n",
"* **``start``** (date or datetime): The range's lower bound\n",
"* **``end``** (date or datetime): The range's upper bound\n",
"* **``value``** (date or datetime): The selected value as a datetime type\n",
"* **``value_throttled``** (datetime): The selected value as a datetime type throttled until mouseup\n",
"* **``step``** (number): The selected step of the slider in seconds, default is 1 minutes, i.e 60 seconds\n",
"\n",
"##### Display\n",
"\n",
"* **``bar_color``** (color): Color of the slider bar as a hexadecimal RGB value\n",
"* **``direction``** (str): Whether the slider should go from left to right ('ltr') or right to left ('rtl')\n",
"* **``disabled``** (boolean): Whether the widget is editable\n",
"* **``name``** (str): The title of the widget\n",
"* **``orientation``** (str): Whether the slider should be displayed in a 'horizontal' or 'vertical' orientation.\n",
"* **``tooltips``** (boolean): Whether to display tooltips on the slider handle\n",
"* **``format``** (string): The datetime's format\n",
"\n",
"___"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"datetime_slider = pn.widgets.DatetimeSlider(name='Datetime Slider', start=dt.datetime(2019, 1, 1), end=dt.datetime(2019, 6, 1), value=dt.datetime(2019, 2, 8, 15, 40, 30))\n",
"\n",
"datetime_slider"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"``DatetimeSlider.value`` returns a datetime type that can be read out or set like other widgets:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"datetime_slider.value"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Controls\n",
"\n",
"The `DatetimeSlider` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"pn.Row(datetime_slider.controls(jslink=True), datetime_slider)"
]
}
],
"metadata": {
"language_info": {
"name": "python",
"pygments_lexer": "ipython3"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
1 change: 1 addition & 0 deletions panel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""
from .browser import BrowserInfo # noqa
from .datetime_picker import DatetimePicker # noqa
from .datetime_slider import DatetimeSlider # noqa
from .esm import AnyWidgetComponent, ReactComponent, ReactiveESM # noqa
from .feed import Feed # noqa
from .icon import ButtonIcon, ToggleIcon, _ClickableIcon # noqa
Expand Down
10 changes: 10 additions & 0 deletions panel/models/datetime_slider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from bokeh.core.properties import Override
from bokeh.models.widgets.sliders import DateSlider


class DatetimeSlider(DateSlider):
""" Slider-based datetime selection widget. """

step = Override(default=60)

format = Override(default="%d %b %Y %H:%M:%S")
56 changes: 56 additions & 0 deletions panel/models/datetime_slider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// adapted from bokeh
// https://github.com/bokeh/bokeh/blob/branch-3.7/bokehjs/src/lib/models/widgets/sliders/date_slider.ts

import {DEFAULT_FORMATTERS} from "@bokehjs/core/util/templating"
import type {SliderSpec} from "@bokehjs/models/widgets/sliders/abstract_slider"
import {NumericalSlider, NumericalSliderView} from "@bokehjs/models/widgets/sliders/numerical_slider"
import type {TickFormatter} from "@bokehjs/models/formatters/tick_formatter"
import type * as p from "@bokehjs/core/properties"
import {isString} from "@bokehjs/core/util/types"

export class DatetimeSliderView extends NumericalSliderView {
declare model: DatetimeSlider

override behaviour = "tap" as const
override connected = [true, false]

protected override _calc_to(): SliderSpec<number> {
const spec = super._calc_to()
spec.step *= 1_000 // step size is in seconds
return spec
}

protected _formatter(value: number, format: string | TickFormatter): string {
if (isString(format)) {
return DEFAULT_FORMATTERS.datetime(value, format, {})
} else {
return format.compute(value)
}
}
}

export namespace DatetimeSlider {
export type Attrs = p.AttrsOf<Props>
export type Props = NumericalSlider.Props
}

export interface DatetimeSlider extends DatetimeSlider.Attrs {}

export class DatetimeSlider extends NumericalSlider {
declare properties: DatetimeSlider.Props
declare __view_type__: DatetimeSliderView

constructor(attrs?: Partial<DatetimeSlider.Attrs>) {
super(attrs)
}
static override __module__ = "panel.models.datetime_slider"

static {
this.prototype.default_view = DatetimeSliderView

this.override<DatetimeSlider.Props>({
step: 60,
format: "%d %b %Y %H:%M:%S",
})
}
}
1 change: 1 addition & 0 deletions panel/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {CustomSelect} from "./customselect"
export {CustomMultiSelect} from "./multiselect"
export {DataTabulator} from "./tabulator"
export {DatetimePicker} from "./datetime_picker"
export {DatetimeSlider} from "./datetime_slider"
export {DeckGLPlot} from "./deckgl"
export {DiscretePlayer} from "./discrete_player"
export {ECharts} from "./echarts"
Expand Down
118 changes: 115 additions & 3 deletions panel/tests/widgets/test_slider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import date, datetime

import numpy as np
import pytest

from bokeh.models import (
Expand All @@ -8,9 +9,9 @@

from panel import config
from panel.widgets import (
DateRangeSlider, DateSlider, DatetimeRangeSlider, DiscreteSlider,
EditableFloatSlider, EditableIntSlider, EditableRangeSlider, FloatSlider,
IntSlider, RangeSlider, StaticText,
DateRangeSlider, DateSlider, DatetimeRangeSlider, DatetimeSlider,
DiscreteSlider, EditableFloatSlider, EditableIntSlider,
EditableRangeSlider, FloatSlider, IntSlider, RangeSlider, StaticText,
)


Expand Down Expand Up @@ -156,6 +157,117 @@ def test_date_slider(document, comm):
assert widget.value == 1620777600000


@pytest.mark.parametrize("start", [date(2018, 9, 1), datetime(2018, 9, 1)])
@pytest.mark.parametrize("end", [date(2018, 9, 10), datetime(2018, 9, 10)])
@pytest.mark.parametrize("value", [date(2018, 9, 4), datetime(2018, 9, 4)])
def test_datetime_slider(document, comm, value, start, end):
datetime_slider = DatetimeSlider(
name='DatetimeSlider',
value=value,
start=start,
end=end,
)
assert datetime_slider.start == start
assert datetime_slider.end == end
assert datetime_slider.value == value

widget = datetime_slider.get_root(document, comm=comm)

assert isinstance(widget, datetime_slider._widget_type)
assert widget.title == 'DatetimeSlider'
assert widget.value == 1536019200000
assert widget.start == 1535760000000.0
assert widget.end == 1536537600000.0

epoch = datetime(1970, 1, 1)
epoch_time = lambda dt: (dt - epoch).total_seconds() * 1000
widget.value = epoch_time(datetime(2018, 9, 3))
datetime_slider._process_events({'value': widget.value})
assert datetime_slider.value == datetime(2018, 9, 3)
datetime_slider._process_events({'value_throttled': epoch_time(datetime(2018, 9, 3))})
assert datetime_slider.value_throttled == datetime(2018, 9, 3)

# Test raw timestamp value:
datetime_slider._process_events({'value': epoch_time(datetime(2018, 9, 4))})
assert datetime_slider.value == datetime(2018, 9, 4)
datetime_slider._process_events({'value_throttled': epoch_time(datetime(2018, 9, 4))})
assert datetime_slider.value_throttled == datetime(2018, 9, 4)

datetime_slider.value = datetime(2018, 9, 6)
assert widget.value == 1536192000000

# Testing throttled mode
with config.set(throttled=True):
datetime_slider._process_events({'value': epoch_time(datetime(2021, 5, 15))})
assert datetime_slider.value == datetime(2018, 9, 6) # no change
datetime_slider._process_events({'value_throttled': epoch_time(datetime(2021, 5, 15))})
assert datetime_slider.value == datetime(2021, 5, 15)

datetime_slider.value = datetime(2021, 5, 12)
assert widget.value == 1620777600000


def test_datetime_slider_np_datetime64(document, comm):
start = np.datetime64('2018-09-01')
end = np.datetime64('2018-09-10')
value = np.datetime64('2018-09-04')

datetime_slider = DatetimeSlider(
name='DatetimeSlider',
value=value,
start=start,
end=end,
)
assert datetime_slider.start == start
assert datetime_slider.end == end
assert datetime_slider.value == value

widget = datetime_slider.get_root(document, comm=comm)

assert isinstance(widget, datetime_slider._widget_type)
assert widget.title == 'DatetimeSlider'
assert widget.value == value
assert widget.start == start
assert widget.end == end

widget.value = np.datetime64('2018-09-03')
datetime_slider._process_events({'value': widget.value})
assert datetime_slider.value == np.datetime64('2018-09-03')
datetime_slider._process_events({'value_throttled': np.datetime64('2018-09-03')})
assert datetime_slider.value_throttled == np.datetime64('2018-09-03')

# Test raw timestamp value:
datetime_slider._process_events({'value': np.datetime64('2018-09-04')})
assert datetime_slider.value == np.datetime64('2018-09-04')
datetime_slider._process_events({'value_throttled': np.datetime64('2018-09-04')})
assert datetime_slider.value_throttled == np.datetime64('2018-09-04')

datetime_slider.value = np.datetime64('2018-09-06')
assert widget.value == np.datetime64('2018-09-06')

# Testing throttled mode
with config.set(throttled=True):
datetime_slider._process_events({'value': np.datetime64('2021-05-15')})
assert datetime_slider.value == np.datetime64('2018-09-06') # no change
datetime_slider._process_events({'value_throttled': np.datetime64('2021-05-15')})
assert datetime_slider.value == np.datetime64('2021-05-15')

datetime_slider.value = np.datetime64('2021-05-12')
assert widget.value == np.datetime64('2021-05-12')


def test_datetime_slider_param_as_datetime_is_readonly():
assert DatetimeSlider.param.as_datetime.readonly
assert DatetimeSlider().param.as_datetime.readonly
datetime_slider = DatetimeSlider(
name='DatetimeSlider',
value=datetime(2018, 9, 4),
start=datetime(2018, 9, 1),
end=datetime(2018, 9, 10),
)
assert datetime_slider.param.as_datetime.readonly


def test_date_range_slider(document, comm):
date_slider = DateRangeSlider(name='DateRangeSlider',
value=(datetime(2018, 9, 2), datetime(2018, 9, 4)),
Expand Down
7 changes: 4 additions & 3 deletions panel/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
RadioButtonGroup, Select, ToggleGroup,
)
from .slider import ( # noqa
DateRangeSlider, DateSlider, DatetimeRangeSlider, DiscreteSlider,
EditableFloatSlider, EditableIntSlider, EditableRangeSlider, FloatSlider,
IntRangeSlider, IntSlider, RangeSlider,
DateRangeSlider, DateSlider, DatetimeRangeSlider, DatetimeSlider,
DiscreteSlider, EditableFloatSlider, EditableIntSlider,
EditableRangeSlider, FloatSlider, IntRangeSlider, IntSlider, RangeSlider,
)
from .speech_to_text import Grammar, GrammarList, SpeechToText # noqa
from .tables import DataFrame, Tabulator # noqa
Expand Down Expand Up @@ -87,6 +87,7 @@
"DateRangeSlider",
"DatetimeRangeSlider",
"DateSlider",
"DatetimeSlider",
"DatetimeInput",
"DatetimePicker",
"DatetimeRangeInput",
Expand Down
31 changes: 31 additions & 0 deletions panel/widgets/slider.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from ..io import state
from ..io.resources import CDN_DIST
from ..layout import Column, Panel, Row
from ..models.datetime_slider import DatetimeSlider as _BkDatetimeSlider
from ..util import (
datetime_as_utctimestamp, edit_readonly, param_reprs, value_as_date,
value_as_datetime,
Expand Down Expand Up @@ -319,6 +320,36 @@ def _process_property_change(self, msg):
return msg


class DatetimeSlider(DateSlider):
"""
The DatetimeSlider widget allows selecting a value within a set of
bounds using a slider. Supports datetime.date, datetime.datetime
and np.datetime64 values. The step size is fixed at 1 minute.
Reference: https://panel.holoviz.org/reference/widgets/DatetimeSlider.html
:Example:
>>> import datetime as dt
>>> DatetimeSlider(
... value=dt.datetime(2025, 1, 1),
... start=dt.datetime(2025, 1, 1),
... end=dt.datetime(2025, 1, 7),
... name="A datetime value"
... )
"""

as_datetime = param.Boolean(default=True, readonly=True, doc="""
Whether to store the date as a datetime.""")

step = param.Number(default=60, bounds=(1, None), doc="""
The step size in seconds. Default is 1 minute, i.e 60 seconds.""")

_property_conversion = staticmethod(value_as_datetime)

_widget_type: ClassVar[type[Model]] = _BkDatetimeSlider


class DiscreteSlider(CompositeWidget, _SliderBase):
"""
The DiscreteSlider widget allows selecting a value from a discrete
Expand Down

0 comments on commit f712587

Please sign in to comment.