Skip to content

Commit c83ddae

Browse files
authored
enh(bokeh): Add support for timedelta axis and support narwhals duration (#6734)
1 parent 8668170 commit c83ddae

3 files changed

Lines changed: 71 additions & 43 deletions

File tree

holoviews/plotting/bokeh/element.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
BOKEH_GE_3_5_0,
7878
BOKEH_GE_3_6_0,
7979
BOKEH_GE_3_7_0,
80+
BOKEH_GE_3_8_0,
8081
TOOL_TYPES,
8182
cds_column_replace,
8283
compute_layout_properties,
@@ -101,6 +102,9 @@
101102
wrap_formatter,
102103
)
103104

105+
if BOKEH_GE_3_8_0:
106+
from bokeh.models.axes import TimedeltaAxis
107+
104108
try:
105109
TOOLS_MAP = Tool._known_aliases
106110
except Exception:
@@ -802,9 +806,10 @@ def _axis_props(self, plots, subplots, element, ranges, pos, *, dim=None,
802806
categorical = True
803807
elif el.get_dimension(dims[0]):
804808
dim_type = el.get_dimension_type(dims[0])
805-
if ((dim_type is np.object_ and issubclass(type(v0), util.datetime_types)) or
806-
dim_type in util.datetime_types):
809+
if isinstance(v0, util.datetime_types) or dim_type in util.datetime_types:
807810
axis_type = 'datetime'
811+
elif BOKEH_GE_3_8_0 and (isinstance(v0, util.timedelta_types) or dim_type in util.timedelta_types):
812+
axis_type = 'timedelta'
808813

809814
norm_opts = self.lookup_options(el, 'norm').options
810815
shared_name = extra_range_name or ('x-main-range' if pos == 0 else 'y-main-range')
@@ -1391,14 +1396,16 @@ def _update_main_ranges(self, element, x_range, y_range, ranges, subcoord=False)
13911396
data_aspect = (self.aspect == 'equal' or self.data_aspect)
13921397
xaxis, yaxis = self.handles['xaxis'], self.handles['yaxis']
13931398
categorical = isinstance(xaxis, CategoricalAxis) or isinstance(yaxis, CategoricalAxis)
1394-
datetime = isinstance(xaxis, DatetimeAxis) or isinstance(yaxis, CategoricalAxis)
1399+
datetime = isinstance(xaxis, DatetimeAxis) or isinstance(yaxis, DatetimeAxis)
1400+
timedelta = BOKEH_GE_3_8_0 and (isinstance(xaxis, TimedeltaAxis) or isinstance(yaxis, TimedeltaAxis))
13951401
range_streams = [s for s in self.streams if isinstance(s, RangeXY)]
13961402

1397-
if data_aspect and (categorical or datetime):
1398-
ax_type = 'categorical' if categorical else 'datetime axes'
1399-
self.param.warning('Cannot set data_aspect if one or both '
1400-
f'axes are {ax_type}, the option will '
1401-
'be ignored.')
1403+
if data_aspect and (categorical or datetime or timedelta):
1404+
self.param.warning(
1405+
'Cannot set data_aspect if one or both axes are '
1406+
'categorical, datetime, or timedelta, data_aspect will '
1407+
'be ignored.'
1408+
)
14021409
elif data_aspect:
14031410
plot = self.handles['plot']
14041411
xspan = r-l if util.is_number(l) and util.is_number(r) else None

holoviews/plotting/bokeh/util.py

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,30 @@
66
from contextlib import contextmanager, suppress
77
from itertools import permutations
88

9-
import bokeh
109
import numpy as np
11-
from bokeh.core.json_encoder import serialize_json # noqa (API import)
1210
from bokeh.core.property.datetime import Datetime
1311
from bokeh.core.validation import silence
1412
from bokeh.core.validation.check import is_silenced
15-
from bokeh.layouts import Column, Row, group_tools
16-
from bokeh.models import (
13+
from bokeh.layouts import group_tools
14+
from bokeh.model import Model
15+
from bokeh.models import CustomJS, tools
16+
from bokeh.models.axes import (
1717
CategoricalAxis,
18-
CopyTool,
19-
CustomJS,
20-
DataRange1d,
2118
DatetimeAxis,
22-
ExamineTool,
23-
FactorRange,
24-
FullscreenTool,
25-
GridBox,
26-
GridPlot,
27-
LayoutDOM,
2819
LinearAxis,
2920
LogAxis,
3021
MercatorAxis,
31-
Model,
32-
Plot,
33-
Range1d,
34-
SaveTool,
35-
Spacer,
36-
Tabs,
37-
Toolbar,
38-
tools,
3922
)
4023
from bokeh.models.formatters import PrintfTickFormatter, TickFormatter
24+
from bokeh.models.layouts import Column, GridBox, LayoutDOM, Row, Spacer, Tabs
25+
from bokeh.models.plots import GridPlot, Plot
26+
from bokeh.models.ranges import DataRange1d, FactorRange, Range1d
4127
from bokeh.models.scales import CategoricalScale, LinearScale, LogScale
4228
from bokeh.models.tickers import BasicTicker, FixedTicker, Ticker
4329
from bokeh.models.widgets import DataTable, Div
4430
from bokeh.plotting import figure
4531
from bokeh.themes import built_in_themes
4632
from bokeh.themes.theme import Theme
47-
from packaging.version import Version
4833

4934
from ...core import util
5035
from ...core.layout import Layout
@@ -60,10 +45,11 @@
6045
isnumeric,
6146
unique_array,
6247
)
48+
from ...core.util.dependencies import _no_import_version
6349
from ...util.warnings import warn
6450
from ..util import dim_axis_label
6551

66-
BOKEH_VERSION = Version(bokeh.__version__).release
52+
BOKEH_VERSION = _no_import_version("bokeh")
6753
BOKEH_GE_3_2_0 = BOKEH_VERSION >= (3, 2, 0)
6854
BOKEH_GE_3_3_0 = BOKEH_VERSION >= (3, 3, 0)
6955
BOKEH_GE_3_4_0 = BOKEH_VERSION >= (3, 4, 0)
@@ -72,6 +58,11 @@
7258
BOKEH_GE_3_7_0 = BOKEH_VERSION >= (3, 7, 0)
7359
BOKEH_GE_3_8_0 = BOKEH_VERSION >= (3, 8, 0)
7460

61+
62+
if BOKEH_GE_3_8_0:
63+
from bokeh.models.axes import TimedeltaAxis
64+
65+
7566
TOOL_TYPES = {
7667
'pan': tools.PanTool,
7768
'xpan': tools.PanTool,
@@ -201,12 +192,12 @@ def compute_plot_size(plot):
201192
width = sum([max([compute_plot_size(f)[0] for f in col]) for col in cols])
202193
height = sum([max([compute_plot_size(f)[1] for f in row]) for row in rows])
203194
return width, height
204-
elif isinstance(plot, (Div, Toolbar)):
195+
elif isinstance(plot, (Div, tools.Toolbar)):
205196
# Cannot compute size for Div or Toolbar
206197
return 0, 0
207198
elif isinstance(plot, (Row, Column, Tabs)):
208199
if not plot.children: return 0, 0
209-
if isinstance(plot, Row) or (isinstance(plot, Toolbar) and plot.toolbar_location not in ['right', 'left']):
200+
if isinstance(plot, Row) or (isinstance(plot, tools.Toolbar) and plot.toolbar_location not in ['right', 'left']):
210201
w_agg, h_agg = (np.sum, np.max)
211202
elif isinstance(plot, Tabs):
212203
w_agg, h_agg = (np.max, np.max)
@@ -430,19 +421,19 @@ def merge_tools(plot_grid, *, disambiguation_properties=None, hide_toolbar=False
430421
and `description` can be used to prevent tools from being merged.
431422
432423
"""
433-
tools = []
424+
plot_tools = []
434425
for row in plot_grid:
435426
for item in row:
436427
if isinstance(item, LayoutDOM):
437428
for p in item.select(dict(type=Plot)):
438-
tools.extend(p.toolbar.tools)
429+
plot_tools.extend(p.toolbar.tools)
439430
if hide_toolbar and hasattr(item, 'toolbar_location'):
440431
item.toolbar_location = None
441432
if isinstance(item, GridPlot):
442433
item.toolbar_location = None
443434

444435
def merge(tool, group):
445-
if issubclass(tool, (SaveTool, CopyTool, ExamineTool, FullscreenTool)):
436+
if issubclass(tool, (tools.SaveTool, tools.CopyTool, tools.ExamineTool, tools.FullscreenTool)):
446437
return tool()
447438
else:
448439
return None
@@ -451,15 +442,15 @@ def merge(tool, group):
451442
disambiguation_properties = {'name', 'icon', 'tags', 'description'}
452443

453444
ignore = set()
454-
for tool in tools:
445+
for tool in plot_tools:
455446
for p in tool.properties_with_values():
456447
if p not in disambiguation_properties:
457448
ignore.add(p)
458449

459450
toolbar_kwargs = {"autohide": autohide}
460-
if tools:
461-
toolbar_kwargs["tools"] = group_tools(tools, merge=merge, ignore=ignore)
462-
return Toolbar(**toolbar_kwargs)
451+
if plot_tools:
452+
toolbar_kwargs["tools"] = group_tools(plot_tools, merge=merge, ignore=ignore)
453+
return tools.Toolbar(**toolbar_kwargs)
463454

464455

465456
def sync_legends(bokeh_layout):
@@ -750,7 +741,7 @@ def filter_toolboxes(plots):
750741
plots.toolbar_location = None
751742
elif hasattr(plots, 'children'):
752743
plots.children = [filter_toolboxes(child) for child in plots.children
753-
if not isinstance(child, Toolbar)]
744+
if not isinstance(child, tools.Toolbar)]
754745
return plots
755746

756747

@@ -1135,7 +1126,7 @@ def match_dim_specs(specs1, specs2):
11351126

11361127

11371128
def get_scale(range_input, axis_type):
1138-
if isinstance(range_input, (DataRange1d, Range1d)) and axis_type in ["linear", "datetime", "mercator", "auto", None]:
1129+
if isinstance(range_input, (DataRange1d, Range1d)) and axis_type in ["linear", "datetime", "mercator", "auto", "timedelta", None]:
11391130
return LinearScale()
11401131
elif isinstance(range_input, (DataRange1d, Range1d)) and axis_type == "log":
11411132
return LogScale()
@@ -1154,6 +1145,8 @@ def get_axis_class(axis_type, range_input, dim): # Copied from bokeh
11541145
return LogAxis, {}
11551146
elif axis_type == "datetime":
11561147
return DatetimeAxis, {}
1148+
elif BOKEH_GE_3_8_0 and axis_type == "timedelta":
1149+
return TimedeltaAxis, {}
11571150
elif axis_type == "mercator":
11581151
return MercatorAxis, dict(dimension='lon' if dim == 0 else 'lat')
11591152
elif axis_type == "auto":
@@ -1183,6 +1176,8 @@ def match_ax_type(ax, range_type):
11831176
return range_type == 'categorical'
11841177
elif isinstance(ax, DatetimeAxis):
11851178
return range_type == 'datetime'
1179+
elif BOKEH_GE_3_8_0 and isinstance(ax, TimedeltaAxis):
1180+
return range_type == 'timedelta'
11861181
else:
11871182
return range_type in ('auto', 'log')
11881183

holoviews/tests/plotting/bokeh/test_elementplot.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from holoviews.core.options import AbbreviatedException
2323
from holoviews.core.util import dt_to_int
2424
from holoviews.element import Curve, HeatMap, Image, Labels, Rectangles, Scatter
25-
from holoviews.plotting.bokeh.util import BOKEH_GE_3_4_0, BOKEH_GE_3_6_0
25+
from holoviews.plotting.bokeh.util import BOKEH_GE_3_4_0, BOKEH_GE_3_6_0, BOKEH_GE_3_8_0
2626
from holoviews.plotting.util import process_cmap
2727
from holoviews.streams import Pipe, PointDraw, Stream
2828
from holoviews.util import render
@@ -875,6 +875,32 @@ def func(data, plot_labels):
875875
# works with no issue
876876
pipe.send(True)
877877

878+
@pytest.mark.skipif(not BOKEH_GE_3_8_0, reason="requires Bokeh >= 3.8")
879+
def test_timedelta_axis_polars(self):
880+
from bokeh.models.axes import TimedeltaAxis
881+
pl = pytest.importorskip("polars")
882+
df = pl.DataFrame(
883+
{
884+
"duration": [1000, 2000, 3000],
885+
"data": [1, 2, 3],
886+
}
887+
).with_columns(pl.col("duration").cast(pl.Duration("ns")))
888+
el = Curve(df)
889+
assert isinstance(render(el).below[0], TimedeltaAxis)
890+
891+
@pytest.mark.skipif(not BOKEH_GE_3_8_0, reason="requires Bokeh >= 3.8")
892+
def test_timedelta_axis_pandas(self):
893+
from bokeh.models.axes import TimedeltaAxis
894+
pd = pytest.importorskip("pandas")
895+
df = pd.DataFrame(
896+
{
897+
"duration": pd.to_timedelta([1000, 2000, 3000]),
898+
"data": [1, 2, 3],
899+
}
900+
)
901+
el = Curve(df)
902+
assert isinstance(render(el).below[0], TimedeltaAxis)
903+
878904

879905
@pytest.mark.usefixtures("bokeh_backend")
880906
@pytest.mark.skipif(not BOKEH_GE_3_4_0, reason="requires Bokeh >= 3.4")

0 commit comments

Comments
 (0)