diff --git a/examples/reference/widgets/DatetimeSlider.ipynb b/examples/reference/widgets/DatetimeSlider.ipynb new file mode 100644 index 0000000000..1be9d8de08 --- /dev/null +++ b/examples/reference/widgets/DatetimeSlider.ipynb @@ -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 +} diff --git a/panel/models/__init__.py b/panel/models/__init__.py index a5616ffbfb..90750473b1 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -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 diff --git a/panel/models/datetime_slider.py b/panel/models/datetime_slider.py new file mode 100644 index 0000000000..665e6519b3 --- /dev/null +++ b/panel/models/datetime_slider.py @@ -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") diff --git a/panel/models/datetime_slider.ts b/panel/models/datetime_slider.ts new file mode 100644 index 0000000000..0fee835f00 --- /dev/null +++ b/panel/models/datetime_slider.ts @@ -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 { + 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 + 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) { + super(attrs) + } + static override __module__ = "panel.models.datetime_slider" + + static { + this.prototype.default_view = DatetimeSliderView + + this.override({ + step: 60, + format: "%d %b %Y %H:%M:%S", + }) + } +} diff --git a/panel/models/index.ts b/panel/models/index.ts index 013635d924..cdcea2da47 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -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" diff --git a/panel/tests/widgets/test_slider.py b/panel/tests/widgets/test_slider.py index 6147c03d9f..a588805b5a 100644 --- a/panel/tests/widgets/test_slider.py +++ b/panel/tests/widgets/test_slider.py @@ -1,5 +1,6 @@ from datetime import date, datetime +import numpy as np import pytest from bokeh.models import ( @@ -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, ) @@ -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)), diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index 02d8180038..b805aadc32 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -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 @@ -87,6 +87,7 @@ "DateRangeSlider", "DatetimeRangeSlider", "DateSlider", + "DatetimeSlider", "DatetimeInput", "DatetimePicker", "DatetimeRangeInput", diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index fc65e60024..2fa58541c4 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -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, @@ -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