From 1de52495ea1da0a871413626ef8d1ec6d953451b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Tue, 24 Sep 2024 18:12:39 +0200 Subject: [PATCH] Improve datetime support for hv.Bars (#6365) --- holoviews/plotting/bokeh/chart.py | 7 +++- holoviews/plotting/mixins.py | 2 +- holoviews/plotting/mpl/chart.py | 39 ++++++++++++------- .../tests/plotting/bokeh/test_barplot.py | 37 +++++++++++++++++- .../tests/plotting/matplotlib/test_barplot.py | 8 ++-- 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 17ef699835..dd76fcc68d 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -880,7 +880,7 @@ def _add_color_data(self, ds, ranges, style, cdim, data, mapping, factors, color def get_data(self, element, ranges, style): # Get x, y, group, stack and color dimensions - group_dim, stack_dim = None, None + group_dim, stack_dim, stack_order = None, None, None if element.ndims == 1: grouping = None elif self.stacked: @@ -893,6 +893,8 @@ def get_data(self, element, ranges, style): else: stack_order = element.dimension_values(1, False) stack_order = list(stack_order) + stack_data = element.dimension_values(stack_dim) + stack_idx = stack_data == stack_data[0] else: grouping = 'grouped' group_dim = element.get_dimension(1) @@ -921,7 +923,8 @@ def get_data(self, element, ranges, style): grouped = {0: element} is_dt = isdatetime(xvals) if is_dt or xvals.dtype.kind not in 'OU': - xdiff = np.abs(np.diff(xvals)) + xslice = stack_idx if stack_order else slice(None) + xdiff = np.abs(np.diff(xvals[xslice])) diff_size = len(np.unique(xdiff)) if diff_size == 0 or (diff_size == 1 and xdiff[0] == 0): xdiff = 1 diff --git a/holoviews/plotting/mixins.py b/holoviews/plotting/mixins.py index 524308a1c7..8da7c7bf7a 100644 --- a/holoviews/plotting/mixins.py +++ b/holoviews/plotting/mixins.py @@ -174,7 +174,7 @@ def get_extents(self, element, ranges, range_type='combined', **kwargs): else: y0, y1 = ranges[vdim]['combined'] - x0, x1 = (l, r) if util.isnumeric(l) and len(element.kdims) == 1 else ('', '') + x0, x1 = (l, r) if (util.isnumeric(l) or isinstance(l, util.datetime_types)) else ('', '') if range_type == 'data': return (x0, y0, x1, y1) diff --git a/holoviews/plotting/mpl/chart.py b/holoviews/plotting/mpl/chart.py index 5da620b859..917b4a6316 100644 --- a/holoviews/plotting/mpl/chart.py +++ b/holoviews/plotting/mpl/chart.py @@ -936,9 +936,12 @@ def _create_bars(self, axis, element, ranges, style): cats = None style_dim = None + xslice = slice(None) if sdim: cats = values['stack'] style_dim = sdim + stack_data = element.dimension_values(sdim) + xslice = stack_data == stack_data[0] elif cdim: cats = values['category'] style_dim = cdim @@ -954,14 +957,15 @@ def _create_bars(self, axis, element, ranges, style): is_dt = isdatetime(xvals) continuous = True if is_dt or xvals.dtype.kind not in 'OU' and not (cdim or len(element.kdims) > 1): - xdiff_vals = date2num(xvals) if is_dt else xvals - xdiff = np.abs(np.diff(xdiff_vals)) - if len(np.unique(xdiff)) == 1: - # if all are same + xvals = xvals[xslice] + xdiff = np.abs(np.diff(xvals)) + diff_size = len(np.unique(xdiff)) + if diff_size == 0 or (diff_size == 1 and xdiff[0] == 0): xdiff = 1 else: xdiff = np.min(xdiff) - width = (1 - self.bar_padding) * xdiff + width = (1 - self.bar_padding) * (date2num(xdiff) / 1000 if is_dt else xdiff) + data_width = (1 - self.bar_padding) * xdiff else: xdiff = len(values.get('category', [None])) width = (1 - self.bar_padding) / xdiff @@ -1050,16 +1054,23 @@ def _create_bars(self, axis, element, ranges, style): axis.legend(title=title, **legend_opts) x_range = ranges[gdim.label]["data"] - if continuous and not is_dt: - if style.get('align', 'center') == 'center': - left_multiplier = 0.5 - right_multiplier = 0.5 - else: - left_multiplier = 0 - right_multiplier = 1 + if style.get('align', 'center') == 'center': + left_multiplier = 0.5 + right_multiplier = 0.5 + else: + left_multiplier = 0 + right_multiplier = 1 + if continuous: + ranges[gdim.label]["data"] = ( + x_range[0] - data_width * left_multiplier, + x_range[1] + data_width * right_multiplier, + ) + else: + locs = [item[0] for item in xticks] + xmin, xmax = min(locs), max(locs) ranges[gdim.label]["data"] = ( - x_range[0] - width * left_multiplier, - x_range[1] + width * right_multiplier + - width * left_multiplier + xmin, + width * right_multiplier + xmax ) return bars, xticks if not continuous else None, ax_dims diff --git a/holoviews/tests/plotting/bokeh/test_barplot.py b/holoviews/tests/plotting/bokeh/test_barplot.py index 272b32103e..777d81826b 100644 --- a/holoviews/tests/plotting/bokeh/test_barplot.py +++ b/holoviews/tests/plotting/bokeh/test_barplot.py @@ -1,6 +1,11 @@ import numpy as np import pandas as pd -from bokeh.models import CategoricalColorMapper, LinearAxis, LinearColorMapper +from bokeh.models import ( + CategoricalColorMapper, + DatetimeAxis, + LinearAxis, + LinearColorMapper, +) from holoviews.core.overlay import NdOverlay, Overlay from holoviews.element import Bars @@ -313,6 +318,29 @@ def test_bars_continuous_datetime(self): plot = bokeh_renderer.get_plot(bars) np.testing.assert_almost_equal(plot.handles["glyph"].width, 69120000.0) + def test_bars_continuous_datetime_timezone_in_overlay(self): + # See: https://github.com/holoviz/holoviews/issues/6364 + bars = Bars((pd.date_range("1/1/2000", periods=10, tz="UTC"), np.random.rand(10))) + overlay = Overlay([bars]) + plot = bokeh_renderer.get_plot(overlay) + assert isinstance(plot.handles["xaxis"], DatetimeAxis) + + def test_bars_continuous_datetime_stacked(self): + # See: https://github.com/holoviz/holoviews/issues/6288 + data = pd.DataFrame({ + "x": pd.to_datetime([ + "2017-01-01T00:00:00", + "2017-01-01T00:00:00", + "2017-01-01T01:00:00", + "2017-01-01T01:00:00", + ]), + "cat": ["A", "B", "A", "B"], + "y": [1, 2, 3, 4], + }) + bars = Bars(data, ["x", "cat"], ["y"]).opts(stacked=True) + plot = bokeh_renderer.get_plot(bars) + assert isinstance(plot.handles["xaxis"], DatetimeAxis) + def test_bars_not_continuous_data_list(self): bars = Bars([("A", 1), ("B", 2), ("C", 3)]) plot = bokeh_renderer.get_plot(bars) @@ -356,3 +384,10 @@ def test_bar_group_stacked(self): ) plot = bokeh_renderer.get_plot(bars) assert plot.handles["glyph"].width == 0.8 + + def test_bar_stacked_stack_variable_sorted(self): + # Check that if the stack dim is ordered + df = pd.DataFrame({"a": [*range(50), *range(50)], "b": sorted("ab" * 50), "c": range(100)}) + bars = Bars(df, kdims=["a", "b"], vdims=["c"]).opts(stacked=True) + plot = bokeh_renderer.get_plot(bars) + assert plot.handles["glyph"].width == 0.8 diff --git a/holoviews/tests/plotting/matplotlib/test_barplot.py b/holoviews/tests/plotting/matplotlib/test_barplot.py index 5e4f85cd1a..7b9d66f50f 100644 --- a/holoviews/tests/plotting/matplotlib/test_barplot.py +++ b/holoviews/tests/plotting/matplotlib/test_barplot.py @@ -45,7 +45,7 @@ def test_bars_not_continuous_data_list(self): bars = Bars([("A", 1), ("B", 2), ("C", 3)]) plot = mpl_renderer.get_plot(bars) ax = plot.handles["axis"] - np.testing.assert_almost_equal(ax.get_xlim(), (-0.54, 2.54)) + np.testing.assert_almost_equal(ax.get_xlim(), (-0.4, 2.4)) assert ax.patches[0].get_width() == 0.8 np.testing.assert_equal(ax.get_xticks(), [0, 1, 2]) np.testing.assert_equal( @@ -69,7 +69,7 @@ def test_bars_group(self): plot = mpl_renderer.get_plot(bars) ax = plot.handles["axis"] - np.testing.assert_almost_equal(ax.get_xlim(), (-0.3233333, 3.8566667)) + np.testing.assert_almost_equal(ax.get_xlim(), (-0.1333333, 3.6666667)) assert ax.patches[0].get_width() == 0.26666666666666666 ticklabels = ax.get_xticklabels() expected = [ @@ -113,7 +113,7 @@ def test_bar_group_stacked(self): plot = mpl_renderer.get_plot(bars) ax = plot.handles["axis"] - np.testing.assert_almost_equal(ax.get_xlim(), (-0.59, 3.59)) + np.testing.assert_almost_equal(ax.get_xlim(), (-0.4, 3.4)) assert ax.patches[0].get_width() == 0.8 ticklabels = ax.get_xticklabels() expected = [ @@ -136,7 +136,7 @@ def test_group_dim(self): plot = mpl_renderer.get_plot(bars) ax = plot.handles["axis"] - np.testing.assert_almost_equal(ax.get_xlim(), (-0.34, 2.74)) + np.testing.assert_almost_equal(ax.get_xlim(), (-0.2, 2.6)) assert ax.patches[0].get_width() == 0.4 assert len(ax.get_xticks()) > 3