pfund-plot 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. pfund_plot/__init__.py +183 -0
  2. pfund_plot/__main__.py +9 -0
  3. pfund_plot/cli/__init__.py +3 -0
  4. pfund_plot/cli/commands/gallery/__init__.py +15 -0
  5. pfund_plot/cli/commands/gallery/gallery_marimo.py +462 -0
  6. pfund_plot/cli/commands/serve.py +21 -0
  7. pfund_plot/cli/main.py +20 -0
  8. pfund_plot/config.py +109 -0
  9. pfund_plot/enums/__init__.py +16 -0
  10. pfund_plot/enums/dataframe_backend.py +6 -0
  11. pfund_plot/enums/display_mode.py +7 -0
  12. pfund_plot/enums/panel_design.py +8 -0
  13. pfund_plot/enums/panel_theme.py +6 -0
  14. pfund_plot/enums/plotting_backend.py +12 -0
  15. pfund_plot/js_tap/components/candlestick.js +9566 -0
  16. pfund_plot/mixins/streaming_market_feed_mixin.py +162 -0
  17. pfund_plot/plots/altair.py +32 -0
  18. pfund_plot/plots/area/__init__.py +82 -0
  19. pfund_plot/plots/area/bokeh.py +151 -0
  20. pfund_plot/plots/bar/__init__.py +80 -0
  21. pfund_plot/plots/bar/bokeh.py +128 -0
  22. pfund_plot/plots/bokeh.py +32 -0
  23. pfund_plot/plots/candlestick/__init__.py +77 -0
  24. pfund_plot/plots/candlestick/bokeh.py +124 -0
  25. pfund_plot/plots/candlestick/svelte.py +161 -0
  26. pfund_plot/plots/holoviews.py +32 -0
  27. pfund_plot/plots/label/__init__.py +43 -0
  28. pfund_plot/plots/label/bokeh.py +89 -0
  29. pfund_plot/plots/layout/__init__.py +98 -0
  30. pfund_plot/plots/layout/layout.py +116 -0
  31. pfund_plot/plots/layout/panel.py +51 -0
  32. pfund_plot/plots/layout/tabs/__init__.py +36 -0
  33. pfund_plot/plots/layout/tabs/panel.py +51 -0
  34. pfund_plot/plots/lazy.py +408 -0
  35. pfund_plot/plots/line/__init__.py +37 -0
  36. pfund_plot/plots/line/bokeh.py +137 -0
  37. pfund_plot/plots/matplotlib.py +32 -0
  38. pfund_plot/plots/plot.py +1131 -0
  39. pfund_plot/plots/plotly.py +32 -0
  40. pfund_plot/plots/scatter/__init__.py +62 -0
  41. pfund_plot/plots/scatter/bokeh.py +158 -0
  42. pfund_plot/plots/scatter/marker.py +107 -0
  43. pfund_plot/plots/ta.py +6 -0
  44. pfund_plot/renderers/base.py +84 -0
  45. pfund_plot/renderers/browser.py +28 -0
  46. pfund_plot/renderers/desktop.py +109 -0
  47. pfund_plot/renderers/notebook.py +92 -0
  48. pfund_plot/typing.py +29 -0
  49. pfund_plot/utils/__init__.py +176 -0
  50. pfund_plot/utils/bokeh.py +177 -0
  51. pfund_plot/widgets/base.py +76 -0
  52. pfund_plot/widgets/datetime_widget.py +221 -0
  53. pfund_plot/widgets/ticker_widget.py +82 -0
  54. pfund_plot-0.0.1.dist-info/METADATA +148 -0
  55. pfund_plot-0.0.1.dist-info/RECORD +57 -0
  56. pfund_plot-0.0.1.dist-info/WHEEL +4 -0
  57. pfund_plot-0.0.1.dist-info/entry_points.txt +6 -0
pfund_plot/typing.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, TypeAlias
4
+
5
+ # NOTE: these are kept under TYPE_CHECKING because the backend libs (plotly, altair,
6
+ # matplotlib, marimo, ...) are OPTIONAL dependencies. Importing them at runtime would
7
+ # make `import pfund_plot.typing` crash for users who didn't install that extra.
8
+ # Guarding here (+ `from __future__ import annotations` in consumers) keeps the names
9
+ # available to type-checkers/IDEs while never importing the optional libs at runtime.
10
+ if TYPE_CHECKING:
11
+ from altair import Chart as AltairChart
12
+ from anywidget import AnyWidget
13
+ from bokeh.plotting._figure import figure as BokehFigure
14
+ from holoviews.core.overlay import Overlay
15
+ from marimo import Html as MarimoHtml
16
+ from matplotlib.figure import Figure as MatplotlibFigure
17
+ from panel.io.threads import StoppableThread
18
+ from panel.layout import Panel
19
+ from panel.widgets import Widget
20
+ from plotly.graph_objects import Figure as PlotlyFigure
21
+
22
+ RawFigure = BokehFigure | PlotlyFigure | AltairChart | MatplotlibFigure
23
+ Figure = RawFigure | AnyWidget
24
+ Plot = Overlay | AnyWidget
25
+ Component = Panel | Widget | MarimoHtml
26
+ RenderedResult = Component | StoppableThread
27
+
28
+ Style: TypeAlias = dict[str, Any]
29
+ Control: TypeAlias = dict[str, Any]
@@ -0,0 +1,176 @@
1
+ # pyright: reportUnknownMemberType=false, reportUnusedImport=false, reportMissingImports=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ import pandas as pd
8
+ from altair import Chart as AltairChart
9
+ from bokeh.model import Model as BokehModel
10
+ from holoviews.core import Dimensioned as HoloviewsDimensioned
11
+ from matplotlib.figure import Figure as MatplotlibFigure
12
+ from narwhals.typing import IntoFrame
13
+ from plotly.graph_objects import Figure as PlotlyFigure
14
+
15
+ from pfund_plot.plots.lazy import LazyPlot
16
+
17
+ import datetime
18
+ from pathlib import Path
19
+
20
+ import narwhals as nw
21
+ from pfeed.enums import DataTool
22
+
23
+
24
+ def load_js(path: str) -> str:
25
+ js_code = Path(path).read_text()
26
+ return f"<script>{js_code}</script>"
27
+
28
+
29
+ def convert_to_datetime(
30
+ date: str | datetime.datetime | pd.Timestamp,
31
+ ) -> datetime.datetime:
32
+ """Convert various date types to a naive UTC datetime.
33
+
34
+ Always returns tz-naive datetime (in UTC) because Panel/Bokeh widgets
35
+ don't handle tz-aware datetimes consistently in their internal validation.
36
+ """
37
+ if isinstance(date, str):
38
+ dt = datetime.datetime.fromisoformat(date)
39
+ elif isinstance(date, datetime.datetime):
40
+ dt = date
41
+ elif isinstance(date, datetime.date):
42
+ dt = datetime.datetime(date.year, date.month, date.day)
43
+ elif isinstance(date, pd.Timestamp):
44
+ dt = date.to_pydatetime()
45
+ else:
46
+ raise ValueError(f"Invalid date type: {type(date)}")
47
+ # convert to UTC then strip tzinfo — Panel widgets don't handle tz-aware datetimes consistently
48
+ if dt.tzinfo is not None:
49
+ dt = dt.astimezone(datetime.UTC).replace(tzinfo=None)
50
+ return dt
51
+
52
+
53
+ def is_daily_data(df: nw.DataFrame[Any]) -> bool:
54
+ """Checks if the 'resolution' column is '1d' and the "ts" column by comparing the first two rows to see if the data is daily data."""
55
+ if df.is_empty():
56
+ return False
57
+ if "resolution" in df.columns and df.select("resolution").row(0)[0] == "1d":
58
+ return True
59
+ assert "date" in df.columns, "DataFrame must have a 'date' column"
60
+ assert isinstance(df.select("date").row(0)[0], datetime.datetime), (
61
+ '"date" column must be of type datetime'
62
+ )
63
+ date1 = df.select("date").row(0)[0]
64
+ if df.shape[0] >= 2:
65
+ date2 = df.select("date").row(1)[0]
66
+ delta = date2 - date1
67
+ return delta == datetime.timedelta(days=1)
68
+ return False
69
+
70
+
71
+ def load_panel_extensions(extensions: list[str] | None = None):
72
+ import panel as pn
73
+
74
+ extensions = extensions or []
75
+ for extension in extensions:
76
+ if extension not in pn.extension._loaded_extensions:
77
+ pn.extension(extension)
78
+ print(f"loaded Panel extension: {extension}")
79
+
80
+
81
+ def match_df_with_data_tool(df: IntoFrame) -> DataTool:
82
+ import pandas as pd
83
+
84
+ try:
85
+ import polars as pl
86
+ except ImportError:
87
+ pl = None
88
+ try:
89
+ import dask.dataframe as dd
90
+ except ImportError:
91
+ dd = None
92
+
93
+ if isinstance(df, pd.DataFrame):
94
+ return DataTool.pandas
95
+ elif pl and isinstance(df, (pl.DataFrame, pl.LazyFrame)):
96
+ return DataTool.polars
97
+ elif dd and isinstance(df, dd.DataFrame):
98
+ return DataTool.dask
99
+ else:
100
+ raise ValueError(
101
+ f"Unsupported dataframe type: {type(df)!r}. "
102
+ + "Expected a pandas.DataFrame, polars.DataFrame/LazyFrame, or dask.dataframe.DataFrame."
103
+ )
104
+
105
+
106
+ def import_hvplot_df_module(data_tool: DataTool | str) -> None:
107
+
108
+ data_tool = DataTool[data_tool.lower()]
109
+ if data_tool == DataTool.pandas:
110
+ import hvplot.pandas
111
+ elif data_tool == DataTool.polars:
112
+ import hvplot.polars
113
+ elif data_tool == DataTool.dask:
114
+ import hvplot.dask # noqa: F401
115
+ else:
116
+ raise ValueError(
117
+ f"Unsupported data tool: {data_tool}, must be one of ['pandas', 'polars', 'dask']"
118
+ )
119
+
120
+
121
+ def convert_to_lazy_plot(
122
+ obj: PlotlyFigure
123
+ | AltairChart
124
+ | MatplotlibFigure
125
+ | BokehModel
126
+ | HoloviewsDimensioned,
127
+ ) -> LazyPlot:
128
+ """Convert plotting library figures to LazyPlot instances."""
129
+ import pfund_plot as plt
130
+
131
+ # Plotly
132
+ try:
133
+ import plotly.graph_objects as go
134
+
135
+ if isinstance(obj, go.Figure):
136
+ return plt.plotly(obj)
137
+ except ImportError:
138
+ pass
139
+
140
+ # Altair (TopLevelMixin is the common base for Chart, LayerChart, HConcatChart, VConcatChart, ConcatChart, FacetChart, RepeatChart)
141
+ try:
142
+ from altair import TopLevelMixin
143
+
144
+ if isinstance(obj, TopLevelMixin):
145
+ return plt.altair(obj)
146
+ except ImportError:
147
+ pass
148
+
149
+ # Matplotlib
150
+ try:
151
+ from matplotlib.figure import Figure as MatplotlibFigure
152
+
153
+ if isinstance(obj, MatplotlibFigure):
154
+ return plt.matplotlib(obj)
155
+ except ImportError:
156
+ pass
157
+
158
+ # Bokeh
159
+ try:
160
+ from bokeh.model import Model as BokehModel
161
+
162
+ if isinstance(obj, BokehModel):
163
+ return plt.bokeh(obj)
164
+ except ImportError:
165
+ pass
166
+
167
+ # Holoviews
168
+ try:
169
+ from holoviews.core import Dimensioned
170
+
171
+ if isinstance(obj, Dimensioned):
172
+ return plt.holoviews(obj)
173
+ except ImportError:
174
+ pass
175
+
176
+ return obj
@@ -0,0 +1,177 @@
1
+ # pyright: reportArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ import narwhals as nw
8
+ from bokeh.models import CustomJSHover, HoverTool
9
+
10
+ DatetimePrecision = Literal["d", "s", "ms"]
11
+
12
+ DATETIME_PRECISION_FORMATS: dict[DatetimePrecision, str] = {
13
+ "d": "%Y-%m-%d",
14
+ "s": "%Y-%m-%d %H:%M:%S",
15
+ "ms": "%Y-%m-%d %H:%M:%S.%3N",
16
+ }
17
+
18
+
19
+ def get_datetime_hover_format(datetime_precision: DatetimePrecision) -> str:
20
+ if datetime_precision not in DATETIME_PRECISION_FORMATS:
21
+ raise ValueError(
22
+ f"Unsupported datetime_precision: {datetime_precision!r}, must be one of {list(DATETIME_PRECISION_FORMATS)}"
23
+ )
24
+ return DATETIME_PRECISION_FORMATS[datetime_precision]
25
+
26
+
27
+ def create_number_formatter_for_hover_tool(
28
+ significant_digits: int = 6,
29
+ ) -> CustomJSHover:
30
+ """Create a number formatter for bokeh hover tools.
31
+ Args:
32
+ significant_digits: Number of significant digits to display
33
+ """
34
+ from bokeh.models import CustomJSHover
35
+
36
+ return CustomJSHover(
37
+ code=f"""
38
+ return value.toPrecision({significant_digits}).replace(/0+$/, '').replace(/\\.$/, '');
39
+ """
40
+ )
41
+
42
+
43
+ def create_hover_col_format(
44
+ df: nw.DataFrame[Any],
45
+ col: str,
46
+ datetime_precision: DatetimePrecision = "s",
47
+ ) -> tuple[tuple[str, str], tuple[str, str | CustomJSHover] | None]:
48
+ """Create the tooltip and formatter for a single column based on its dtype.
49
+
50
+ Returns:
51
+ (tooltip, formatter_entry or None)
52
+ - tooltip: e.g. ("date", "@{date}{%Y-%m-%d %H:%M:%S}")
53
+ - formatter_entry: e.g. ("@{date}", "datetime"), or None if no formatter needed
54
+ """
55
+ num_formatter = create_number_formatter_for_hover_tool()
56
+ schema = df.collect_schema()
57
+ col_dtype = schema[col]
58
+ is_datetime = (
59
+ "datetime" in str(col_dtype).lower() or "date" in str(col_dtype).lower()
60
+ )
61
+ if is_datetime:
62
+ date_format = get_datetime_hover_format(datetime_precision)
63
+ return (col, f"@{{{col}}}{{{date_format}}}"), (f"@{{{col}}}", "datetime")
64
+ elif col_dtype.is_numeric():
65
+ return (col, f"@{{{col}}}{{custom}}"), (f"@{{{col}}}", num_formatter)
66
+ else:
67
+ return (col, f"@{{{col}}}"), None
68
+
69
+
70
+ def _bundle_hover_config(
71
+ df: nw.DataFrame[Any],
72
+ x_col: str | None,
73
+ y_cols: list[str],
74
+ datetime_precision: DatetimePrecision = "s",
75
+ ) -> tuple[list[tuple[str, str]], dict[str, str | CustomJSHover]]:
76
+ """Build a single tooltip that bundles x + all y_cols together.
77
+
78
+ Only works when a single renderer's ColumnDataSource contains all the columns
79
+ (e.g. single-y plots, or the scatter workaround). For multi-y overlays where
80
+ hvplot creates separate renderers per y column, use create_hover_col_format()
81
+ to build per-column tooltips instead.
82
+ """
83
+ tooltips: list[tuple[str, str]] = []
84
+ formatters: dict[str, str | CustomJSHover] = {}
85
+ cols = ([x_col] if x_col is not None else []) + y_cols
86
+ for col in cols:
87
+ tooltip, formatter_entry = create_hover_col_format(df, col, datetime_precision)
88
+ tooltips.append(tooltip)
89
+ if formatter_entry is not None:
90
+ formatters[formatter_entry[0]] = formatter_entry[1]
91
+ return tooltips, formatters
92
+
93
+
94
+ def create_bundled_hover_tool(
95
+ df: nw.DataFrame[Any],
96
+ x_col: str | None,
97
+ y_cols: list[str],
98
+ datetime_precision: DatetimePrecision = "s",
99
+ ) -> HoverTool:
100
+ """Create a HoverTool that shows all specified columns in one tooltip.
101
+
102
+ Only use when a single renderer's ColumnDataSource contains all the columns
103
+ (e.g. single-y plots, or the scatter workaround where one Scatter holds the
104
+ full dataframe). For multi-y overlays with separate renderers per y column
105
+ """
106
+ from bokeh.models import HoverTool
107
+
108
+ tooltips, formatters = _bundle_hover_config(df, x_col, y_cols, datetime_precision)
109
+ return HoverTool(tooltips=tooltips, formatters=formatters, mode="vline")
110
+
111
+
112
+ def create_hover_scatter(
113
+ df: nw.DataFrame[Any],
114
+ x_col: str | None,
115
+ y_cols: list[str],
116
+ datetime_precision: DatetimePrecision = "s",
117
+ marker: str | None = None,
118
+ ) -> Any:
119
+ """Workaround: create a scatter overlay that provides hover tooltips.
120
+
121
+ Some hvplot chart types (e.g. area) render as Bokeh glyphs that transform
122
+ the data (e.g. Patch polygons), destroying the original column values in
123
+ the ColumnDataSource.
124
+
125
+ This works by overlaying scatter points whose ColumnDataSource contains the
126
+ original, untransformed data, so a HoverTool attached to it can reference
127
+ the real column values.
128
+
129
+ Args:
130
+ marker: marker shape string (e.g. 'circle', 'cross'). If None, points
131
+ are invisible (alpha=0) but still large enough for Bokeh's hit-testing.
132
+ """
133
+ import holoviews as hv
134
+
135
+ native_df = df.to_native()
136
+ hover_tool = create_bundled_hover_tool(df, x_col, y_cols, datetime_precision)
137
+ scatter_opts: dict[str, Any] = dict(
138
+ size=7 if marker else 15,
139
+ alpha=1 if marker else 0,
140
+ marker=marker or "circle",
141
+ tools=[hover_tool],
142
+ hover_mode="vline",
143
+ )
144
+ kdims = [x_col or "index"]
145
+
146
+ if len(y_cols) == 1:
147
+ return hv.Scatter(native_df, kdims=kdims, vdims=[y_cols[0]]).opts(
148
+ **scatter_opts
149
+ )
150
+ else:
151
+ scatters = {
152
+ y_col: hv.Scatter(native_df, kdims=kdims, vdims=[y_col]).opts(
153
+ **scatter_opts
154
+ )
155
+ for y_col in y_cols
156
+ }
157
+ return hv.NdOverlay(scatters)
158
+
159
+
160
+ def create_vline_hover_opts(
161
+ df: nw.DataFrame[Any],
162
+ x_col: str | None,
163
+ y_cols: list[str],
164
+ datetime_precision: DatetimePrecision = "s",
165
+ ) -> Any:
166
+ """This shows the tooltip without needing the cursor to hover over the data points
167
+ Useful in plots like plt.line() for multi-line plots where the tooltips are separated for each line
168
+ """
169
+ from holoviews import opts
170
+
171
+ tooltips, formatters = _bundle_hover_config(df, x_col, y_cols, datetime_precision)
172
+ return opts.Curve(
173
+ tools=["hover"],
174
+ hover_mode="vline",
175
+ hover_tooltips=[("series", "@{Variable}"), *tooltips],
176
+ hover_formatters=formatters,
177
+ )
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any, ClassVar
6
+
7
+ if TYPE_CHECKING:
8
+ import narwhals as nw
9
+ import panel as pn
10
+
11
+ from pfund_plot.plots.plot import MessageKey, StreamingDfs
12
+
13
+
14
+ class BaseWidget(ABC):
15
+ # Columns this widget requires in the df (e.g. ["date"]).
16
+ REQUIRED_COLS: ClassVar[list[str] | None] = None
17
+
18
+ def __init__(
19
+ self,
20
+ df: nw.DataFrame[Any],
21
+ control: dict[str, Any],
22
+ update_callback: Callable[[nw.DataFrame[Any]], None],
23
+ ):
24
+ self._df = df
25
+ self._control: dict[str, Any] = control
26
+ self._update_callback = update_callback
27
+ self._overlays: list[BaseWidget] = []
28
+
29
+ @abstractmethod
30
+ def update_df(self, df: nw.DataFrame[Any]) -> None: ...
31
+
32
+ @abstractmethod
33
+ def get_panel_objects(self) -> list[pn.widgets.Widget]:
34
+ """Return the Panel widget objects to be placed in the toolbox."""
35
+ ...
36
+
37
+ def can_merge_with(self, other: BaseWidget) -> bool:
38
+ """Can this widget merge with another widget of the same class?
39
+ Same class = same REQUIRED_COLS = always compatible.
40
+ """
41
+ return True
42
+
43
+ def add_overlay(self, other: BaseWidget) -> None:
44
+ """Register an overlay widget so this widget's actions also update the overlay."""
45
+ self._overlays.append(other)
46
+
47
+
48
+ class BaseStreamingWidget(ABC):
49
+ def __init__(
50
+ self,
51
+ streaming_dfs: StreamingDfs,
52
+ active_key: MessageKey,
53
+ update_callback: Callable[[MessageKey], None],
54
+ ):
55
+ self._streaming_dfs = streaming_dfs
56
+ self._active_key = active_key
57
+ self._update_callback = update_callback
58
+ self._overlays: list[BaseStreamingWidget] = []
59
+
60
+ @abstractmethod
61
+ def update_streaming_state(self, streaming_dfs: StreamingDfs) -> None: ...
62
+
63
+ @abstractmethod
64
+ def get_panel_objects(self) -> list[pn.widgets.Widget]:
65
+ """Return the Panel widget objects to be placed in the toolbox."""
66
+ ...
67
+
68
+ def can_merge_with(self, other: BaseStreamingWidget) -> bool:
69
+ """Can this widget merge with another streaming widget of the same class?
70
+ Merge if they operate on the same set of msg_keys (i.e. same feed).
71
+ """
72
+ return set(self._streaming_dfs.keys()) == set(other._streaming_dfs.keys())
73
+
74
+ def add_overlay(self, other: BaseStreamingWidget) -> None:
75
+ """Register an overlay streaming widget so this widget's actions also update the overlay."""
76
+ self._overlays.append(other)
@@ -0,0 +1,221 @@
1
+ # pyright: reportUnknownMemberType=false, reportGeneralTypeIssues=false, reportUnusedParameter=false, reportUnknownVariableType=false, reportArgumentType=false, reportUnknownArgumentType=false, reportAttributeAccessIssue=false
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any, ClassVar
6
+
7
+ if TYPE_CHECKING:
8
+ from narwhals.typing import Frame
9
+ from param.parameterized import Event
10
+
11
+ import datetime
12
+
13
+ import narwhals as nw
14
+ import panel as pn
15
+
16
+ from pfund_plot.utils import convert_to_datetime
17
+ from pfund_plot.widgets.base import BaseWidget
18
+
19
+
20
+ def round_date(dt: datetime.datetime, to: str = "floor") -> datetime.datetime:
21
+ """Round a datetime to the nearest second boundary.
22
+
23
+ When a user drags the DatetimeRangeSlider, Bokeh snaps BOTH slider handles
24
+ to the nearest step boundary (e.g. every 5000ms). This snapping truncates
25
+ sub-second precision, producing a value like 04:55:19.000 even if the original
26
+ datetime was 04:55:19.732. When that snapped value is synced back to the
27
+ DatetimeRangeInput, Panel validates it against the input's start/end bounds.
28
+ If the bound still has the original sub-second precision (04:55:19.732),
29
+ the snapped value (04:55:19.000) falls outside the bound and validation fails.
30
+
31
+ To prevent this, we round the bounds: floor the start date and ceil the end date,
32
+ so the bounds are always wider than any value the slider step quantization can produce.
33
+
34
+ Args:
35
+ dt: the datetime to round
36
+ to: 'floor' to round down (strip microseconds), 'ceil' to round up (next whole second)
37
+ """
38
+ if to == "floor":
39
+ return dt.replace(microsecond=0)
40
+ elif to == "ceil":
41
+ return dt.replace(microsecond=0) + datetime.timedelta(seconds=1)
42
+ else:
43
+ raise ValueError(f"Invalid rounding direction: {to}, must be 'floor' or 'ceil'")
44
+
45
+
46
+ class DatetimeRangeWidget(BaseWidget):
47
+ REQUIRED_COLS: ClassVar[list[str] | None] = ["date"]
48
+
49
+ def __init__(
50
+ self,
51
+ df: nw.DataFrame[Any],
52
+ control: dict[str, Any],
53
+ update_callback: Callable[[nw.DataFrame[Any]], None],
54
+ ):
55
+ super().__init__(df, control, update_callback)
56
+ date_col = self._df["date"]
57
+ num_data_shown = date_col.len()
58
+ if "num_data" in control and control["num_data"] is not None:
59
+ num_data_shown = min(control["num_data"], num_data_shown)
60
+ start_date, end_date = (
61
+ convert_to_datetime(date_col[0]),
62
+ convert_to_datetime(date_col[-1]),
63
+ )
64
+ start_date = round_date(start_date, to="floor")
65
+ end_date = round_date(end_date, to="ceil")
66
+ data_shown_start_date = round_date(
67
+ convert_to_datetime(date_col[-num_data_shown]), to="floor"
68
+ )
69
+ self._datetime_range_input = pn.widgets.DatetimeRangeInput(
70
+ name="Datetime Range Input",
71
+ start=start_date,
72
+ end=end_date,
73
+ value=(data_shown_start_date, end_date),
74
+ width=150,
75
+ )
76
+ self._input_watcher = self._datetime_range_input.param.watch(
77
+ self._update_datetime_range_input, "value"
78
+ )
79
+ self._datetime_range_slider = pn.widgets.DatetimeRangeSlider(
80
+ name="Period",
81
+ start=start_date,
82
+ end=end_date,
83
+ value=(data_shown_start_date, end_date),
84
+ step=control["slider_step"] or self._derive_slider_step(),
85
+ )
86
+ self._slider_watcher = self._datetime_range_slider.param.watch(
87
+ self._update_datetime_range_slider, "value"
88
+ )
89
+ # self._max_data = pn.rx(df.shape[0])
90
+ # self._data_slider = pn.widgets.IntSlider(
91
+ # name='Number of Most Recent Data Points',
92
+ # value=num_data_shown,
93
+ # start=control['num_data'],
94
+ # end=self._max_data,
95
+ # step=control['slider_step']
96
+ # )
97
+ # self._data_slider.param.watch(self._update_data_slider, 'value')
98
+ # self._show_all_button = pn.widgets.Button(name='Show All', button_type='primary')
99
+ # self._show_all_button.on_click(self._max_out_data_slider)
100
+
101
+ @property
102
+ def datetime_range_input(self) -> pn.widgets.DatetimeRangeInput:
103
+ return self._datetime_range_input
104
+
105
+ @property
106
+ def datetime_range_slider(self) -> pn.widgets.DatetimeRangeSlider:
107
+ return self._datetime_range_slider
108
+
109
+ def get_panel_objects(self) -> list[pn.widgets.Widget]:
110
+ return [self._datetime_range_input, self._datetime_range_slider]
111
+
112
+ @staticmethod
113
+ def _filter_df(
114
+ df: nw.DataFrame[Any],
115
+ start_date: datetime.datetime,
116
+ end_date: datetime.datetime,
117
+ ) -> Frame:
118
+ return df.filter((nw.col("date") >= start_date) & (nw.col("date") <= end_date))
119
+
120
+ def _fan_out_to_overlays(
121
+ self, start_date: datetime.datetime, end_date: datetime.datetime
122
+ ) -> None:
123
+ """Filter each overlay widget's full df by the same date range and trigger its update.
124
+ Must be called BEFORE the parent's _update_callback so the DynamicMap re-render
125
+ picks up the updated overlay dfs.
126
+ """
127
+ for overlay_widget in self._overlays:
128
+ filtered = self._filter_df(overlay_widget._df, start_date, end_date)
129
+ overlay_widget._update_callback(filtered)
130
+
131
+ def _derive_slider_step(self) -> int:
132
+ date_col = self._df["date"]
133
+ # infer resolution from data
134
+ resolution_ms = (date_col[1] - date_col[0]).total_seconds() * 1000
135
+ # use 5x resolution as step, so user can move meaningfully but not too coarsely
136
+ slider_step = int(resolution_ms * 5)
137
+ return slider_step
138
+
139
+ def _update_datetime_range_input(self, event: Event):
140
+ start_date, end_date = self._datetime_range_input.value
141
+ # silently update the _datetime_range_slider as well, temporarily remove the watcher
142
+ self._datetime_range_slider.param.unwatch(self._slider_watcher)
143
+ try:
144
+ _ = self._datetime_range_slider.param.update(value=(start_date, end_date))
145
+ finally:
146
+ self._slider_watcher = self._datetime_range_slider.param.watch(
147
+ self._update_datetime_range_slider, "value"
148
+ )
149
+ # update overlay dfs BEFORE parent re-render so DynamicMap picks them up
150
+ self._fan_out_to_overlays(start_date, end_date)
151
+ df_filtered = self._filter_df(self._df, start_date, end_date)
152
+ self._update_callback(df_filtered)
153
+
154
+ def _update_datetime_range_slider(self, event: Event):
155
+ start_date, end_date = self._datetime_range_slider.value
156
+ # silently update the _datetime_range_input as well, temporarily remove the watcher
157
+ self._datetime_range_input.param.unwatch(self._input_watcher)
158
+ try:
159
+ _ = self._datetime_range_input.param.update(value=(start_date, end_date))
160
+ finally:
161
+ self._input_watcher = self._datetime_range_input.param.watch(
162
+ self._update_datetime_range_input, "value"
163
+ )
164
+ # update overlay dfs BEFORE parent re-render so DynamicMap picks them up
165
+ self._fan_out_to_overlays(start_date, end_date)
166
+ df_filtered = self._filter_df(self._df, start_date, end_date)
167
+ self._update_callback(df_filtered)
168
+
169
+ def update_df(self, df: nw.DataFrame[Any]):
170
+ """Update widget bounds and df reference for new df (currently only used when receiving streaming data)."""
171
+ self._df = df
172
+ if self._df.shape[0] < 2:
173
+ raise ValueError("df must have at least 2 rows")
174
+ date_col = df["date"]
175
+ new_end = round_date(convert_to_datetime(date_col[-1]), to="ceil")
176
+
177
+ self._datetime_range_input.param.unwatch(self._input_watcher)
178
+ self._datetime_range_slider.param.unwatch(self._slider_watcher)
179
+ try:
180
+ # check if slider was at the end before updating
181
+ # NOTE: strip tzinfo before comparing because Panel/Bokeh may return
182
+ # tz-aware datetimes from .value after user interaction
183
+ slider_start, slider_end = self._datetime_range_slider.value
184
+
185
+ was_at_end = convert_to_datetime(slider_end) >= convert_to_datetime(
186
+ self._datetime_range_slider.end
187
+ )
188
+
189
+ # expand bounds
190
+ self._datetime_range_slider.end = new_end
191
+ self._datetime_range_input.end = new_end
192
+
193
+ # auto-extend value to include new data if slider was at the end
194
+ if was_at_end:
195
+ _ = self._datetime_range_slider.param.update(
196
+ value=(slider_start, new_end)
197
+ )
198
+ _ = self._datetime_range_input.param.update(
199
+ value=(slider_start, new_end)
200
+ )
201
+ finally:
202
+ self._input_watcher = self._datetime_range_input.param.watch(
203
+ self._update_datetime_range_input, "value"
204
+ )
205
+ self._slider_watcher = self._datetime_range_slider.param.watch(
206
+ self._update_datetime_range_slider, "value"
207
+ )
208
+
209
+ # @property
210
+ # def data_slider(self) -> pn.widgets.IntSlider:
211
+ # return self._data_slider
212
+
213
+ # @property
214
+ # def show_all_button(self) -> pn.widgets.Button:
215
+ # return self._show_all_button
216
+
217
+ # def _update_data_slider(self, event: Event):
218
+ # self._update_callback(self._df.tail(self._data_slider.value))
219
+
220
+ # def _max_out_data_slider(self, event: Event):
221
+ # self._data_slider.value = self._max_data.rx.value