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.
- pfund_plot/__init__.py +183 -0
- pfund_plot/__main__.py +9 -0
- pfund_plot/cli/__init__.py +3 -0
- pfund_plot/cli/commands/gallery/__init__.py +15 -0
- pfund_plot/cli/commands/gallery/gallery_marimo.py +462 -0
- pfund_plot/cli/commands/serve.py +21 -0
- pfund_plot/cli/main.py +20 -0
- pfund_plot/config.py +109 -0
- pfund_plot/enums/__init__.py +16 -0
- pfund_plot/enums/dataframe_backend.py +6 -0
- pfund_plot/enums/display_mode.py +7 -0
- pfund_plot/enums/panel_design.py +8 -0
- pfund_plot/enums/panel_theme.py +6 -0
- pfund_plot/enums/plotting_backend.py +12 -0
- pfund_plot/js_tap/components/candlestick.js +9566 -0
- pfund_plot/mixins/streaming_market_feed_mixin.py +162 -0
- pfund_plot/plots/altair.py +32 -0
- pfund_plot/plots/area/__init__.py +82 -0
- pfund_plot/plots/area/bokeh.py +151 -0
- pfund_plot/plots/bar/__init__.py +80 -0
- pfund_plot/plots/bar/bokeh.py +128 -0
- pfund_plot/plots/bokeh.py +32 -0
- pfund_plot/plots/candlestick/__init__.py +77 -0
- pfund_plot/plots/candlestick/bokeh.py +124 -0
- pfund_plot/plots/candlestick/svelte.py +161 -0
- pfund_plot/plots/holoviews.py +32 -0
- pfund_plot/plots/label/__init__.py +43 -0
- pfund_plot/plots/label/bokeh.py +89 -0
- pfund_plot/plots/layout/__init__.py +98 -0
- pfund_plot/plots/layout/layout.py +116 -0
- pfund_plot/plots/layout/panel.py +51 -0
- pfund_plot/plots/layout/tabs/__init__.py +36 -0
- pfund_plot/plots/layout/tabs/panel.py +51 -0
- pfund_plot/plots/lazy.py +408 -0
- pfund_plot/plots/line/__init__.py +37 -0
- pfund_plot/plots/line/bokeh.py +137 -0
- pfund_plot/plots/matplotlib.py +32 -0
- pfund_plot/plots/plot.py +1131 -0
- pfund_plot/plots/plotly.py +32 -0
- pfund_plot/plots/scatter/__init__.py +62 -0
- pfund_plot/plots/scatter/bokeh.py +158 -0
- pfund_plot/plots/scatter/marker.py +107 -0
- pfund_plot/plots/ta.py +6 -0
- pfund_plot/renderers/base.py +84 -0
- pfund_plot/renderers/browser.py +28 -0
- pfund_plot/renderers/desktop.py +109 -0
- pfund_plot/renderers/notebook.py +92 -0
- pfund_plot/typing.py +29 -0
- pfund_plot/utils/__init__.py +176 -0
- pfund_plot/utils/bokeh.py +177 -0
- pfund_plot/widgets/base.py +76 -0
- pfund_plot/widgets/datetime_widget.py +221 -0
- pfund_plot/widgets/ticker_widget.py +82 -0
- pfund_plot-0.0.1.dist-info/METADATA +148 -0
- pfund_plot-0.0.1.dist-info/RECORD +57 -0
- pfund_plot-0.0.1.dist-info/WHEEL +4 -0
- pfund_plot-0.0.1.dist-info/entry_points.txt +6 -0
pfund_plot/plots/plot.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
# pyright: reportAttributeAccessIssue=false, reportOptionalMemberAccess=false, reportConstantRedefinition=false, reportUnusedParameter=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeAlias, cast
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from anywidget import AnyWidget
|
|
9
|
+
from holoviews.streams import Pipe
|
|
10
|
+
from narwhals.typing import IntoFrame
|
|
11
|
+
from panel.pane import Pane
|
|
12
|
+
from panel.widgets import Widget as PanelWidget
|
|
13
|
+
from pfeed.feeds.market_feed import MarketFeed
|
|
14
|
+
from pfeed.requests.market_feed_stream_request import MarketFeedStreamRequest
|
|
15
|
+
from pfeed.streaming.streaming_message import StreamingMessage
|
|
16
|
+
from pfund.typing import ProductName, ResolutionRepr
|
|
17
|
+
|
|
18
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
19
|
+
from pfund_plot.renderers.base import BaseRenderer
|
|
20
|
+
from pfund_plot.typing import (
|
|
21
|
+
Component,
|
|
22
|
+
Control,
|
|
23
|
+
Figure,
|
|
24
|
+
Plot,
|
|
25
|
+
RawFigure,
|
|
26
|
+
RenderedResult,
|
|
27
|
+
Style,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
MessageKey: TypeAlias = tuple[ProductName, ResolutionRepr]
|
|
31
|
+
StreamingDfs: TypeAlias = dict[MessageKey, Any] # MessageKey -> nw.DataFrame
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import importlib
|
|
35
|
+
import time
|
|
36
|
+
from threading import Thread
|
|
37
|
+
|
|
38
|
+
import narwhals as nw
|
|
39
|
+
import panel as pn
|
|
40
|
+
from pfund_kit.style import RichColor, TextStyle, cprint
|
|
41
|
+
|
|
42
|
+
from pfund_plot.enums import DisplayMode, NotebookType, PlottingBackend
|
|
43
|
+
from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BasePlot:
|
|
47
|
+
REQUIRED_COLS: ClassVar[list[str] | None] = None
|
|
48
|
+
# Columns that are used when present but not required; missing ones are silently skipped.
|
|
49
|
+
OPTIONAL_COLS: ClassVar[list[str]] = []
|
|
50
|
+
REQUIRED_DATA: ClassVar[bool] = True
|
|
51
|
+
SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend] | None] = None
|
|
52
|
+
SUPPORT_STREAMING: ClassVar[bool] = False
|
|
53
|
+
SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]] | None] = None
|
|
54
|
+
SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]] | None] = None
|
|
55
|
+
_ChosenWidgetClasses: ClassVar[list[type[BaseWidget]]] = []
|
|
56
|
+
_ChosenStreamingWidgetClasses: ClassVar[list[type[BaseStreamingWidget]]] = []
|
|
57
|
+
# Wrapper class like CandlestickStyle, used to access the style() function based on backend
|
|
58
|
+
style: ClassVar[Any | None] = None
|
|
59
|
+
# Wrapper class like CandlestickControl, used to access the control() function based on backend
|
|
60
|
+
control: ClassVar[Any | None] = None
|
|
61
|
+
_style: ClassVar[Style | None] = None # actual style dictionary
|
|
62
|
+
_control: ClassVar[Control | None] = None # actual control dictionary
|
|
63
|
+
_backend: ClassVar[PlottingBackend | None] = None
|
|
64
|
+
_mode: ClassVar[DisplayMode | None] = None
|
|
65
|
+
|
|
66
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> LazyPlot:
|
|
67
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
68
|
+
|
|
69
|
+
# Dynamically inject streaming mixin based on feed type
|
|
70
|
+
data = args[0] if args else kwargs.get("data")
|
|
71
|
+
cls = cls._check_if_inject_streaming_mixin(cls, data)
|
|
72
|
+
|
|
73
|
+
instance: BasePlot = object.__new__(cls)
|
|
74
|
+
# manually call __init__ to initialize the instance
|
|
75
|
+
instance.__init__(*args, **kwargs)
|
|
76
|
+
return LazyPlot(instance)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _check_if_inject_streaming_mixin(
|
|
80
|
+
cls: type[BasePlot], data: Any
|
|
81
|
+
) -> type[BasePlot]: # pyright: ignore[reportSelfClsParameterName]
|
|
82
|
+
"""Dynamically inject streaming mixin based on feed type if not already in MRO."""
|
|
83
|
+
if not cls.SUPPORT_STREAMING:
|
|
84
|
+
return cls
|
|
85
|
+
from pfeed.feeds.base_feed import BaseFeed
|
|
86
|
+
|
|
87
|
+
if isinstance(data, BaseFeed):
|
|
88
|
+
from pfeed.feeds.market_feed import MarketFeed
|
|
89
|
+
|
|
90
|
+
if isinstance(data, MarketFeed):
|
|
91
|
+
from pfund_plot.mixins.streaming_market_feed_mixin import (
|
|
92
|
+
StreamingMarketFeedMixin,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if StreamingMarketFeedMixin not in cls.__mro__:
|
|
96
|
+
return type(
|
|
97
|
+
cls.__name__,
|
|
98
|
+
(StreamingMarketFeedMixin, cls),
|
|
99
|
+
{"__module__": cls.__module__},
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"Unsupported feed type for streaming: {type(data)}")
|
|
103
|
+
return cls
|
|
104
|
+
|
|
105
|
+
def __deepcopy__(self, memo: dict) -> BasePlot:
|
|
106
|
+
from copy import deepcopy
|
|
107
|
+
|
|
108
|
+
cls = type(self)
|
|
109
|
+
# bypass __new__ which would call __init__ and wrap in LazyPlot
|
|
110
|
+
new = object.__new__(cls)
|
|
111
|
+
memo[id(self)] = new
|
|
112
|
+
for k, v in self.__dict__.items():
|
|
113
|
+
setattr(new, k, deepcopy(v, memo))
|
|
114
|
+
return new
|
|
115
|
+
|
|
116
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
117
|
+
super().__init_subclass__(**kwargs)
|
|
118
|
+
class_name = cls.__name__
|
|
119
|
+
assert cls.SUPPORTED_BACKENDS is not None, (
|
|
120
|
+
f"SUPPORTED_BACKENDS is not defined for class {class_name}"
|
|
121
|
+
)
|
|
122
|
+
if cls.SUPPORTED_WIDGETS is not None:
|
|
123
|
+
cls._ChosenWidgetClasses = list(cls.SUPPORTED_WIDGETS)
|
|
124
|
+
if cls.SUPPORTED_STREAMING_WIDGETS is not None:
|
|
125
|
+
cls._ChosenStreamingWidgetClasses = list(cls.SUPPORTED_STREAMING_WIDGETS)
|
|
126
|
+
for backend in cls.SUPPORTED_BACKENDS:
|
|
127
|
+
if cls.style is not None:
|
|
128
|
+
assert hasattr(cls.style, backend.value), (
|
|
129
|
+
f"style for {backend} is not defined for class {class_name}"
|
|
130
|
+
)
|
|
131
|
+
if cls.control is not None:
|
|
132
|
+
assert hasattr(cls.control, backend.value), (
|
|
133
|
+
f"control for {backend} is not defined for class {class_name}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
data: IntoFrame | MarketFeed | None = None,
|
|
139
|
+
x: str | None = None,
|
|
140
|
+
y: str | list[str] | None = None,
|
|
141
|
+
callback: Callable[..., Any] | None = None,
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
plot_kwargs: dict[str, Any] | None = None,
|
|
144
|
+
**reactive_params: Any,
|
|
145
|
+
):
|
|
146
|
+
"""
|
|
147
|
+
Args:
|
|
148
|
+
data: The dataframe for static plot or pfeed's feed object for streaming plot
|
|
149
|
+
x: the column name of the x-axis, if None, will use the index or the first column of the dataframe
|
|
150
|
+
y: the column name of the y-axis, if None, will plot all numeric columns of the dataframe
|
|
151
|
+
callback: A reactive callback function. When provided with **reactive_params,
|
|
152
|
+
auto-creates widgets that re-fetch data on change.
|
|
153
|
+
name: Display name for this plot (used as label when widgets are shown alongside overlays).
|
|
154
|
+
Defaults to the class name lowercased (e.g. "candlestick", "line").
|
|
155
|
+
plot_kwargs: keyword arguments for the plot function.
|
|
156
|
+
e.g. if the plot function is hvplot.line, plot_kwargs will be passed to hvplot.line(**plot_kwargs)
|
|
157
|
+
**reactive_params: name=value pairs for reactive widgets (e.g. ticker=["BTC", "ETH"]).
|
|
158
|
+
Requires callback to be set.
|
|
159
|
+
"""
|
|
160
|
+
from pfeed.feeds.base_feed import BaseFeed
|
|
161
|
+
from pfund_kit.utils import get_notebook_type
|
|
162
|
+
|
|
163
|
+
# check if data is a dataframe or a feed
|
|
164
|
+
if not isinstance(data, BaseFeed):
|
|
165
|
+
self._df: nw.DataFrame[Any] = data
|
|
166
|
+
self._feed: MarketFeed | None = None
|
|
167
|
+
else:
|
|
168
|
+
self._df: nw.DataFrame[Any] | None = None
|
|
169
|
+
self._feed: MarketFeed | None = data
|
|
170
|
+
self.name: str = name or self._class_name
|
|
171
|
+
self._reactive_params: dict[str, Any] = reactive_params
|
|
172
|
+
self._reactive_callback: Callable[..., Any] | None = callback
|
|
173
|
+
self._reactive_widgets: dict[str, PanelWidget] = {}
|
|
174
|
+
self._setup()
|
|
175
|
+
self._x: str | None = x
|
|
176
|
+
self._y: str | list[str] | None = y
|
|
177
|
+
if self._df is not None:
|
|
178
|
+
self._df = self._standardize_df(self._df)
|
|
179
|
+
self._x = self._derive_x_col(self._df, self._x)
|
|
180
|
+
self._plot_kwargs: dict[str, Any] = plot_kwargs or {}
|
|
181
|
+
self._pane_kwargs: dict[str, Any] = {}
|
|
182
|
+
self._anywidget: AnyWidget | None = None
|
|
183
|
+
self._plot: Plot | None = None
|
|
184
|
+
self._pane: Pane | None = None
|
|
185
|
+
self._widgets: dict[type[BaseWidget], BaseWidget] = {}
|
|
186
|
+
self._active_msg_key: MessageKey | None = None
|
|
187
|
+
self._streaming_dfs: dict[MessageKey, nw.DataFrame[Any]] = {}
|
|
188
|
+
self._streaming_pipe: Pipe | None = None
|
|
189
|
+
self._streaming_thread: Thread | None = None
|
|
190
|
+
self._streaming_widgets: dict[
|
|
191
|
+
type[BaseStreamingWidget], BaseStreamingWidget
|
|
192
|
+
] = {}
|
|
193
|
+
self._component: Component | None = None
|
|
194
|
+
self._overlays: list[BasePlot] = []
|
|
195
|
+
self._holoviews_opts: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
|
196
|
+
self._parent_plot: BasePlot | None = (
|
|
197
|
+
None # set when this plot is used as an overlay
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
self._notebook_type: NotebookType | None = get_notebook_type()
|
|
201
|
+
|
|
202
|
+
cls = self.__class__
|
|
203
|
+
if cls._backend is None:
|
|
204
|
+
cls.set_backend(cls.SUPPORTED_BACKENDS[0])
|
|
205
|
+
if cls._mode is None:
|
|
206
|
+
cls.set_mode(
|
|
207
|
+
DisplayMode.notebook if self._notebook_type else DisplayMode.browser
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Initialize instance variables
|
|
211
|
+
self._backend: PlottingBackend | None = None
|
|
212
|
+
self._mode: DisplayMode | None = None
|
|
213
|
+
self._renderer: BaseRenderer | None = None
|
|
214
|
+
self._style: Style | None = None
|
|
215
|
+
self._control: Control | None = None
|
|
216
|
+
self._ChosenWidgetClasses: list[type[BaseWidget]] = list(
|
|
217
|
+
cls._ChosenWidgetClasses
|
|
218
|
+
)
|
|
219
|
+
self._ChosenStreamingWidgetClasses: list[type[BaseStreamingWidget]] = list(
|
|
220
|
+
cls._ChosenStreamingWidgetClasses
|
|
221
|
+
)
|
|
222
|
+
self._set_backend(cls._backend)
|
|
223
|
+
self._set_mode(cls._mode)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _is_hvplot(plot: Plot) -> bool:
|
|
227
|
+
from holoviews.core import Dimensioned
|
|
228
|
+
|
|
229
|
+
return isinstance(plot, Dimensioned)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def _class_name(self) -> str:
|
|
233
|
+
return self.__class__.__name__.lower()
|
|
234
|
+
|
|
235
|
+
def _standardize_df(self, df: IntoFrame) -> nw.DataFrame[Any]:
|
|
236
|
+
import datetime
|
|
237
|
+
|
|
238
|
+
df = nw.from_native(df)
|
|
239
|
+
if isinstance(df, nw.LazyFrame):
|
|
240
|
+
df = df.collect()
|
|
241
|
+
|
|
242
|
+
if self.REQUIRED_COLS:
|
|
243
|
+
missing_cols = [col for col in self.REQUIRED_COLS if col not in df.columns]
|
|
244
|
+
if missing_cols:
|
|
245
|
+
raise ValueError(f"Missing required columns: {missing_cols}")
|
|
246
|
+
|
|
247
|
+
# find a date-like column (case-insensitive) and ensure it's a proper datetime type
|
|
248
|
+
col_lookup = {col.lower(): col for col in df.columns}
|
|
249
|
+
date_col: str | None = None
|
|
250
|
+
for alias in ("date", "datetime", "timestamp"):
|
|
251
|
+
if alias in col_lookup:
|
|
252
|
+
date_col = col_lookup[alias]
|
|
253
|
+
break
|
|
254
|
+
if date_col is not None and df.shape[0] > 0:
|
|
255
|
+
date_value = df.select(date_col).row(0)[0]
|
|
256
|
+
if not isinstance(date_value, datetime.datetime):
|
|
257
|
+
try:
|
|
258
|
+
df = df.with_columns(
|
|
259
|
+
nw.col(date_col).str.to_datetime(format=None),
|
|
260
|
+
)
|
|
261
|
+
except Exception as err:
|
|
262
|
+
raise TypeError(
|
|
263
|
+
f"Column '{date_col}' cannot be converted to datetime (got {type(date_value).__name__})"
|
|
264
|
+
) from err
|
|
265
|
+
# normalize to naive UTC — Panel/Bokeh widgets don't handle tz-aware datetimes consistently
|
|
266
|
+
date_dtype = df.collect_schema()[date_col]
|
|
267
|
+
if hasattr(date_dtype, "time_zone") and date_dtype.time_zone is not None:
|
|
268
|
+
df = df.with_columns(
|
|
269
|
+
nw.col(date_col)
|
|
270
|
+
.dt.convert_time_zone("UTC")
|
|
271
|
+
.dt.replace_time_zone(None)
|
|
272
|
+
)
|
|
273
|
+
return df
|
|
274
|
+
|
|
275
|
+
def _create_widgets(self) -> None:
|
|
276
|
+
def _has_required_cols(WidgetClass: type[BaseWidget]) -> bool:
|
|
277
|
+
"""Check if the df has the columns required by the widget."""
|
|
278
|
+
required = WidgetClass.REQUIRED_COLS
|
|
279
|
+
if not required:
|
|
280
|
+
return True
|
|
281
|
+
return all(col in self._df.columns for col in required)
|
|
282
|
+
|
|
283
|
+
from pfund_plot.config import get_config
|
|
284
|
+
|
|
285
|
+
if (
|
|
286
|
+
get_config().disable_widgets
|
|
287
|
+
or self._control is None
|
|
288
|
+
or not self._control.get("widgets", True)
|
|
289
|
+
):
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
for WidgetClass in self._ChosenWidgetClasses:
|
|
293
|
+
if WidgetClass not in self._widgets and _has_required_cols(WidgetClass):
|
|
294
|
+
self._widgets[WidgetClass] = WidgetClass(
|
|
295
|
+
self._df, self._control, self._update_pane
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if self.is_streaming():
|
|
299
|
+
for WidgetClass in self._ChosenStreamingWidgetClasses:
|
|
300
|
+
if WidgetClass not in self._streaming_widgets:
|
|
301
|
+
self._streaming_widgets[WidgetClass] = WidgetClass(
|
|
302
|
+
self._streaming_dfs,
|
|
303
|
+
self._active_msg_key,
|
|
304
|
+
self._update_active_stream,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _update_widgets(self, df: nw.DataFrame[Any]) -> None:
|
|
308
|
+
if not self._widgets and not self._streaming_widgets:
|
|
309
|
+
self._create_widgets()
|
|
310
|
+
for widget in self._widgets.values():
|
|
311
|
+
widget.update_df(df)
|
|
312
|
+
for widget in self._streaming_widgets.values():
|
|
313
|
+
widget.update_streaming_state(self._streaming_dfs)
|
|
314
|
+
|
|
315
|
+
def _append_toolbox(
|
|
316
|
+
self,
|
|
317
|
+
widget_objects: list[PanelWidget],
|
|
318
|
+
label: str = "",
|
|
319
|
+
position: Literal["top", "bottom"] = "bottom",
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Add a group of widget objects to the component, optionally with a label header.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
position: "bottom" appends after the plot, "top" inserts before it.
|
|
325
|
+
"""
|
|
326
|
+
if not widget_objects:
|
|
327
|
+
return
|
|
328
|
+
items: list[PanelWidget] = list(widget_objects)
|
|
329
|
+
if label:
|
|
330
|
+
items.insert(0, pn.pane.Markdown(f"**{label}**"))
|
|
331
|
+
toolbox = pn.FlexBox(
|
|
332
|
+
*items,
|
|
333
|
+
align_items="center",
|
|
334
|
+
justify_content="center",
|
|
335
|
+
)
|
|
336
|
+
if position == "top":
|
|
337
|
+
if isinstance(self._component, pn.Column):
|
|
338
|
+
self._component.insert(0, toolbox)
|
|
339
|
+
else:
|
|
340
|
+
self._component = pn.Column(toolbox, self._component)
|
|
341
|
+
else:
|
|
342
|
+
if isinstance(self._component, pn.Column):
|
|
343
|
+
self._component.append(toolbox)
|
|
344
|
+
else:
|
|
345
|
+
self._component = pn.Column(self._component, toolbox)
|
|
346
|
+
|
|
347
|
+
def _resolve_widget_merging(
|
|
348
|
+
self,
|
|
349
|
+
parent_widgets: dict[type, BaseWidget | BaseStreamingWidget],
|
|
350
|
+
overlay_widgets_attr: str,
|
|
351
|
+
) -> set[type]:
|
|
352
|
+
"""Merge parent widgets with overlay widgets of the same class.
|
|
353
|
+
|
|
354
|
+
For each parent widget, if an overlay has the same widget class and
|
|
355
|
+
can_merge_with returns True, register the overlay as a participant
|
|
356
|
+
(one widget controls both). Track which overlay widget classes were
|
|
357
|
+
merged so they can be skipped during individual rendering.
|
|
358
|
+
|
|
359
|
+
Returns the set of widget classes that were merged.
|
|
360
|
+
"""
|
|
361
|
+
merged: set[type] = set()
|
|
362
|
+
for WidgetClass, widget in parent_widgets.items():
|
|
363
|
+
for overlay in self._overlays:
|
|
364
|
+
overlay_widgets = getattr(overlay, overlay_widgets_attr)
|
|
365
|
+
if WidgetClass in overlay_widgets and widget.can_merge_with(
|
|
366
|
+
overlay_widgets[WidgetClass]
|
|
367
|
+
):
|
|
368
|
+
widget.add_overlay(overlay_widgets[WidgetClass])
|
|
369
|
+
merged.add(WidgetClass)
|
|
370
|
+
return merged
|
|
371
|
+
|
|
372
|
+
def _collect_unmerged_overlay_widgets(
|
|
373
|
+
self,
|
|
374
|
+
overlay_widgets_attr: str,
|
|
375
|
+
merged_classes: set[type],
|
|
376
|
+
position: Literal["top", "bottom"] = "bottom",
|
|
377
|
+
) -> bool:
|
|
378
|
+
"""Render overlay widgets that weren't merged with the parent, each with a label.
|
|
379
|
+
|
|
380
|
+
Returns True if any unmerged overlay widgets were found.
|
|
381
|
+
"""
|
|
382
|
+
has_unmerged = False
|
|
383
|
+
for overlay in self._overlays:
|
|
384
|
+
overlay_widgets = getattr(overlay, overlay_widgets_attr)
|
|
385
|
+
overlay_objects: list[PanelWidget] = []
|
|
386
|
+
for WidgetClass, widget in overlay_widgets.items():
|
|
387
|
+
if WidgetClass in merged_classes:
|
|
388
|
+
continue
|
|
389
|
+
overlay_objects.extend(widget.get_panel_objects())
|
|
390
|
+
if overlay_objects:
|
|
391
|
+
has_unmerged = True
|
|
392
|
+
self._append_toolbox(overlay_objects, label=overlay.name, position=position)
|
|
393
|
+
return has_unmerged
|
|
394
|
+
|
|
395
|
+
def _attach_widgets(self) -> None:
|
|
396
|
+
"""Append non-streaming widgets at the bottom of the component.
|
|
397
|
+
|
|
398
|
+
Smart merging: parent widgets with matching class are auto-merged
|
|
399
|
+
so one widget (e.g. datetime slider) controls both the parent plot
|
|
400
|
+
and its overlays. Overlay-only widgets that can't be merged are
|
|
401
|
+
rendered individually with a label.
|
|
402
|
+
"""
|
|
403
|
+
if not self._widgets and not self._streaming_widgets:
|
|
404
|
+
self._create_widgets()
|
|
405
|
+
|
|
406
|
+
# Set overlay parent refs and create their widgets
|
|
407
|
+
for overlay in self._overlays:
|
|
408
|
+
overlay._parent_plot = self
|
|
409
|
+
if not overlay._widgets and not overlay._streaming_widgets:
|
|
410
|
+
overlay._create_widgets()
|
|
411
|
+
|
|
412
|
+
# Merge + collect parent widgets
|
|
413
|
+
merged = self._resolve_widget_merging(self._widgets, "_widgets")
|
|
414
|
+
widget_objects: list[PanelWidget] = []
|
|
415
|
+
for widget in self._widgets.values():
|
|
416
|
+
widget_objects.extend(widget.get_panel_objects())
|
|
417
|
+
# Unmerged overlay widgets
|
|
418
|
+
has_unmerged = self._collect_unmerged_overlay_widgets("_widgets", merged)
|
|
419
|
+
label = self.name if has_unmerged else ""
|
|
420
|
+
self._append_toolbox(widget_objects, label=label)
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _infer_widget(name: str, value: Any) -> PanelWidget:
|
|
424
|
+
"""Infer and create a Panel widget from a reactive param's name and value.
|
|
425
|
+
|
|
426
|
+
The value's Python type determines which widget is created:
|
|
427
|
+
Panel widget → used as-is (escape hatch for full customization)
|
|
428
|
+
list → Select dropdown (first item is default)
|
|
429
|
+
(min, max, val) → IntSlider if val is int, FloatSlider if float
|
|
430
|
+
bool → Toggle button
|
|
431
|
+
str → TextInput
|
|
432
|
+
|
|
433
|
+
The param name is used as the widget label,
|
|
434
|
+
with underscores replaced by spaces and title-cased (e.g. 'num_bars' → 'Num Bars').
|
|
435
|
+
"""
|
|
436
|
+
if isinstance(value, pn.widgets.Widget):
|
|
437
|
+
return value
|
|
438
|
+
if isinstance(value, list):
|
|
439
|
+
return pn.widgets.Select(
|
|
440
|
+
name=name.replace("_", " ").title(), options=value, value=value[0]
|
|
441
|
+
)
|
|
442
|
+
if isinstance(value, tuple) and len(value) == 3:
|
|
443
|
+
min_val, max_val, default = value
|
|
444
|
+
if isinstance(default, float):
|
|
445
|
+
return pn.widgets.FloatSlider(
|
|
446
|
+
name=name.replace("_", " ").title(),
|
|
447
|
+
start=min_val,
|
|
448
|
+
end=max_val,
|
|
449
|
+
value=default,
|
|
450
|
+
)
|
|
451
|
+
return pn.widgets.IntSlider(
|
|
452
|
+
name=name.replace("_", " ").title(),
|
|
453
|
+
start=min_val,
|
|
454
|
+
end=max_val,
|
|
455
|
+
value=default,
|
|
456
|
+
)
|
|
457
|
+
# NOTE: bool check must come before any future int check, since bool is a subclass of int
|
|
458
|
+
if isinstance(value, bool):
|
|
459
|
+
return pn.widgets.Toggle(name=name.replace("_", " ").title(), value=value)
|
|
460
|
+
if isinstance(value, str):
|
|
461
|
+
return pn.widgets.TextInput(
|
|
462
|
+
name=name.replace("_", " ").title(), value=value
|
|
463
|
+
)
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"Cannot create widget from {type(value).__name__} for param '{name}'. "
|
|
466
|
+
+ "Pass a list, tuple (min, max, default), bool, str, or a Panel widget."
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def _create_reactive_widgets(self) -> None:
|
|
470
|
+
"""Create Panel widgets from reactive params. Binding is deferred to _attach_reactive_widgets."""
|
|
471
|
+
if not self._reactive_params:
|
|
472
|
+
return
|
|
473
|
+
for name, value in self._reactive_params.items():
|
|
474
|
+
self._reactive_widgets[name] = self._infer_widget(name, value)
|
|
475
|
+
|
|
476
|
+
def _setup_reactive_binding(self, merged_params: set[str] | None = None) -> None:
|
|
477
|
+
callback = self._reactive_callback
|
|
478
|
+
assert callback is not None, (
|
|
479
|
+
"callback is required when reactive_params are provided"
|
|
480
|
+
)
|
|
481
|
+
widgets = self._reactive_widgets
|
|
482
|
+
overlays = self._overlays
|
|
483
|
+
|
|
484
|
+
def on_change(*events: Any) -> None:
|
|
485
|
+
kwargs = {name: w.value for name, w in widgets.items()}
|
|
486
|
+
df = callback(**kwargs)
|
|
487
|
+
df = self._standardize_df(df)
|
|
488
|
+
self._update_df(df)
|
|
489
|
+
|
|
490
|
+
# Fan out merged params to overlays
|
|
491
|
+
if merged_params:
|
|
492
|
+
for overlay in overlays:
|
|
493
|
+
if overlay._reactive_callback is not None:
|
|
494
|
+
overlay_kwargs = {
|
|
495
|
+
n: w.value for n, w in overlay._reactive_widgets.items()
|
|
496
|
+
}
|
|
497
|
+
# Override merged params with parent's current values
|
|
498
|
+
for p in merged_params:
|
|
499
|
+
if p in overlay_kwargs:
|
|
500
|
+
overlay_kwargs[p] = widgets[p].value
|
|
501
|
+
overlay_df = overlay._reactive_callback(**overlay_kwargs)
|
|
502
|
+
overlay_df = overlay._standardize_df(overlay_df)
|
|
503
|
+
overlay._update_df(overlay_df)
|
|
504
|
+
|
|
505
|
+
self._update_pane(df)
|
|
506
|
+
|
|
507
|
+
for widget in widgets.values():
|
|
508
|
+
_ = widget.param.watch(on_change, "value")
|
|
509
|
+
|
|
510
|
+
def _attach_reactive_widgets(self) -> None:
|
|
511
|
+
"""Insert reactive widgets at the top of the component.
|
|
512
|
+
|
|
513
|
+
Smart merging: if an overlay has the exact same reactive params (names
|
|
514
|
+
AND values), its widgets are merged — one set of widgets controls both
|
|
515
|
+
plots via fan-out. Otherwise the overlay's widgets are shown separately
|
|
516
|
+
with a label and their own binding.
|
|
517
|
+
"""
|
|
518
|
+
if not self._reactive_widgets and not any(
|
|
519
|
+
overlay._reactive_params for overlay in self._overlays
|
|
520
|
+
):
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
# Create overlay reactive widgets (mirrors _attach_widgets pattern)
|
|
524
|
+
for overlay in self._overlays:
|
|
525
|
+
if overlay._reactive_params and not overlay._reactive_widgets:
|
|
526
|
+
overlay._create_reactive_widgets()
|
|
527
|
+
|
|
528
|
+
# Determine which overlays can be fully merged (all params match by name AND value)
|
|
529
|
+
mergeable_overlays: set[int] = set()
|
|
530
|
+
for i, overlay in enumerate(self._overlays):
|
|
531
|
+
if (
|
|
532
|
+
overlay._reactive_params
|
|
533
|
+
and self._reactive_params == overlay._reactive_params
|
|
534
|
+
):
|
|
535
|
+
mergeable_overlays.add(i)
|
|
536
|
+
|
|
537
|
+
# Set up parent binding with fan-out to mergeable overlays
|
|
538
|
+
if self._reactive_widgets:
|
|
539
|
+
merged_params = (
|
|
540
|
+
set(self._reactive_params.keys()) if mergeable_overlays else None
|
|
541
|
+
)
|
|
542
|
+
self._setup_reactive_binding(merged_params)
|
|
543
|
+
|
|
544
|
+
# Show parent reactive widgets (with label when overlays have separate widgets)
|
|
545
|
+
if self._reactive_widgets:
|
|
546
|
+
has_unmerged_overlays = any(
|
|
547
|
+
i not in mergeable_overlays and overlay._reactive_widgets
|
|
548
|
+
for i, overlay in enumerate(self._overlays)
|
|
549
|
+
)
|
|
550
|
+
label = self.name if has_unmerged_overlays else ""
|
|
551
|
+
self._append_toolbox(
|
|
552
|
+
list(self._reactive_widgets.values()), label=label, position="top"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Show non-mergeable overlay reactive widgets separately
|
|
556
|
+
for i, overlay in enumerate(self._overlays):
|
|
557
|
+
if i in mergeable_overlays or not overlay._reactive_widgets:
|
|
558
|
+
continue
|
|
559
|
+
overlay._setup_reactive_binding()
|
|
560
|
+
self._append_toolbox(
|
|
561
|
+
list(overlay._reactive_widgets.values()),
|
|
562
|
+
label=overlay.name,
|
|
563
|
+
position="top",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _attach_streaming_widgets(self) -> None:
|
|
567
|
+
"""Insert streaming widgets at the top of the component."""
|
|
568
|
+
# Merge + collect parent streaming widgets
|
|
569
|
+
merged = self._resolve_widget_merging(
|
|
570
|
+
self._streaming_widgets, "_streaming_widgets"
|
|
571
|
+
)
|
|
572
|
+
streaming_objects: list[PanelWidget] = []
|
|
573
|
+
for widget in self._streaming_widgets.values():
|
|
574
|
+
streaming_objects.extend(widget.get_panel_objects())
|
|
575
|
+
# Unmerged overlay streaming widgets (insert first so they end up below parent's)
|
|
576
|
+
has_unmerged = self._collect_unmerged_overlay_widgets(
|
|
577
|
+
"_streaming_widgets", merged, position="top"
|
|
578
|
+
)
|
|
579
|
+
label = self.name if has_unmerged else ""
|
|
580
|
+
self._append_toolbox(streaming_objects, label=label, position="top")
|
|
581
|
+
|
|
582
|
+
def _add_overlay(self, overlay: BasePlot):
|
|
583
|
+
self._overlays.append(overlay)
|
|
584
|
+
|
|
585
|
+
def _add_opts(self, args: tuple[Any, ...], kwargs: dict[str, Any]):
|
|
586
|
+
self._holoviews_opts.append((args, kwargs))
|
|
587
|
+
|
|
588
|
+
def _update_df(self, df: nw.DataFrame[Any]):
|
|
589
|
+
self._df = df
|
|
590
|
+
|
|
591
|
+
def _update_active_stream(self, msg_key: MessageKey) -> None:
|
|
592
|
+
"""Switch which streaming product is displayed."""
|
|
593
|
+
self._active_msg_key = msg_key
|
|
594
|
+
if msg_key in self._streaming_dfs:
|
|
595
|
+
df = self._streaming_dfs[msg_key]
|
|
596
|
+
self._update_df(df)
|
|
597
|
+
self._update_pane(df)
|
|
598
|
+
# Update other widgets (e.g. datetime range) for the new product's data
|
|
599
|
+
for widget in self._widgets.values():
|
|
600
|
+
widget.update_df(df)
|
|
601
|
+
|
|
602
|
+
def _update_streaming_df(self, msg_key: MessageKey, df: nw.DataFrame[Any]):
|
|
603
|
+
# if exceeds max_data, truncate the dataframe
|
|
604
|
+
df = self._truncate_streaming_df(df)
|
|
605
|
+
self._streaming_dfs[msg_key] = df
|
|
606
|
+
# update the data reference if the received message key is the active key
|
|
607
|
+
if msg_key == self._active_msg_key:
|
|
608
|
+
self._update_df(df)
|
|
609
|
+
|
|
610
|
+
def _create_component(self) -> None:
|
|
611
|
+
if self._style:
|
|
612
|
+
height = self._style.get("total_height")
|
|
613
|
+
width = self._style.get("width")
|
|
614
|
+
name = self._style.get("title", self.name)
|
|
615
|
+
else:
|
|
616
|
+
height = None
|
|
617
|
+
width = None
|
|
618
|
+
name = self.name
|
|
619
|
+
self._component = pn.Column(
|
|
620
|
+
self._pane,
|
|
621
|
+
name=name,
|
|
622
|
+
sizing_mode=self._get_sizing_mode(height, width),
|
|
623
|
+
height=height,
|
|
624
|
+
width=width,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
@classmethod
|
|
628
|
+
def get_supported_backends(cls) -> list[PlottingBackend]:
|
|
629
|
+
return cast(list[PlottingBackend], cls.SUPPORTED_BACKENDS)
|
|
630
|
+
|
|
631
|
+
@classmethod
|
|
632
|
+
def get_required_cols(cls) -> list[str]:
|
|
633
|
+
if cls.REQUIRED_COLS is None:
|
|
634
|
+
return []
|
|
635
|
+
return cls.REQUIRED_COLS
|
|
636
|
+
|
|
637
|
+
@classmethod
|
|
638
|
+
def get_supported_widgets(cls) -> list[type[BaseWidget]]:
|
|
639
|
+
if cls.SUPPORTED_WIDGETS is None:
|
|
640
|
+
return []
|
|
641
|
+
return cls.SUPPORTED_WIDGETS
|
|
642
|
+
|
|
643
|
+
@classmethod
|
|
644
|
+
def get_supported_streaming_widgets(cls) -> list[type[BaseStreamingWidget]]:
|
|
645
|
+
if cls.SUPPORTED_STREAMING_WIDGETS is None:
|
|
646
|
+
return []
|
|
647
|
+
return cls.SUPPORTED_STREAMING_WIDGETS
|
|
648
|
+
|
|
649
|
+
@classmethod
|
|
650
|
+
def is_support_streaming(cls) -> bool:
|
|
651
|
+
return cls.SUPPORT_STREAMING
|
|
652
|
+
|
|
653
|
+
def is_streaming(self):
|
|
654
|
+
return self.is_support_streaming() and self._feed is not None
|
|
655
|
+
|
|
656
|
+
def is_in_notebook_mode(self) -> bool:
|
|
657
|
+
return self._mode == DisplayMode.notebook
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _derive_y_cols(
|
|
661
|
+
df: nw.DataFrame[Any], x: str | None, y: str | list[str] | None
|
|
662
|
+
) -> list[str]:
|
|
663
|
+
if y is None:
|
|
664
|
+
y_cols = [c for c in df.columns if c != x]
|
|
665
|
+
elif isinstance(y, str):
|
|
666
|
+
y_cols = [y]
|
|
667
|
+
else:
|
|
668
|
+
y_cols = y
|
|
669
|
+
return y_cols
|
|
670
|
+
|
|
671
|
+
@staticmethod
|
|
672
|
+
def _derive_x_col(df: nw.DataFrame[Any], x: str | None) -> str | None:
|
|
673
|
+
x_col = x
|
|
674
|
+
native_df = df.to_native()
|
|
675
|
+
if x is None:
|
|
676
|
+
if "date" in df.columns:
|
|
677
|
+
# auto-resolve x to 'date' column when not specified,
|
|
678
|
+
# ensures consistent axis dimensions across overlays
|
|
679
|
+
x_col = "date"
|
|
680
|
+
elif hasattr(native_df, "index") and native_df.index.name is not None:
|
|
681
|
+
x_col = native_df.index.name
|
|
682
|
+
return x_col
|
|
683
|
+
|
|
684
|
+
def _setup(self):
|
|
685
|
+
from pfeed.feeds.streaming_feed_mixin import StreamingFeedMixin
|
|
686
|
+
|
|
687
|
+
from pfund_plot.utils import import_hvplot_df_module, match_df_with_data_tool
|
|
688
|
+
|
|
689
|
+
if self.REQUIRED_DATA:
|
|
690
|
+
assert self._df is not None or self._feed is not None, (
|
|
691
|
+
f"{self._class_name} requires either a dataframe or a feed"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if self._df is not None:
|
|
695
|
+
import_hvplot_df_module(match_df_with_data_tool(self._df))
|
|
696
|
+
|
|
697
|
+
if self.is_streaming() and self.REQUIRED_DATA:
|
|
698
|
+
assert self.SUPPORT_STREAMING, (
|
|
699
|
+
f"{self._class_name} does not support streaming"
|
|
700
|
+
)
|
|
701
|
+
if not isinstance(self._feed, StreamingFeedMixin):
|
|
702
|
+
raise ValueError(
|
|
703
|
+
"feed must be a pfeed's Feed object that supports streaming"
|
|
704
|
+
)
|
|
705
|
+
# set pipeline mode to True for streaming to standardize the run method to be feed.run()
|
|
706
|
+
assert self._feed.is_pipeline(), f"{self._feed} must be in pipeline mode"
|
|
707
|
+
assert self._feed._num_workers is None, (
|
|
708
|
+
f"Ray is not supported in streaming plot, {self._feed.__class__.__name__} 'num_workers' must be None"
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if self._reactive_params:
|
|
712
|
+
assert self._reactive_callback is not None, (
|
|
713
|
+
"callback is required when reactive_params are provided"
|
|
714
|
+
)
|
|
715
|
+
assert self._feed is None, (
|
|
716
|
+
"reactive params are not supported with streaming feed"
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
def _create(self):
|
|
720
|
+
if self._pane is None:
|
|
721
|
+
self._create_pane()
|
|
722
|
+
if not self._widgets:
|
|
723
|
+
self._create_widgets()
|
|
724
|
+
if self._reactive_params and not self._reactive_widgets:
|
|
725
|
+
self._create_reactive_widgets()
|
|
726
|
+
if self._component is None:
|
|
727
|
+
self._create_component()
|
|
728
|
+
self._attach_widgets()
|
|
729
|
+
self._attach_streaming_widgets()
|
|
730
|
+
self._attach_reactive_widgets()
|
|
731
|
+
|
|
732
|
+
def _add_periodic_callback(self, callback: Callable[..., Any]):
|
|
733
|
+
"""Add a periodic callback to the renderer.
|
|
734
|
+
Args:
|
|
735
|
+
periodic_callback: The periodic callback to add.
|
|
736
|
+
it is created by `panel.state.add_periodic_callback`.
|
|
737
|
+
"""
|
|
738
|
+
periodic_callback = pn.state.add_periodic_callback(
|
|
739
|
+
callback,
|
|
740
|
+
period=self._control["update_interval"], # in ms
|
|
741
|
+
start=False,
|
|
742
|
+
)
|
|
743
|
+
self._renderer.add_periodic_callback(periodic_callback)
|
|
744
|
+
|
|
745
|
+
def _on_streaming_callback(self, msg: StreamingMessage) -> StreamingMessage:
|
|
746
|
+
raise NotImplementedError(f"{self._class_name} does not support streaming")
|
|
747
|
+
|
|
748
|
+
def _is_streaming_ready(self) -> bool:
|
|
749
|
+
return bool(self._streaming_dfs)
|
|
750
|
+
|
|
751
|
+
def _create_streaming_row(self, msg: StreamingMessage) -> nw.DataFrame[Any]:
|
|
752
|
+
raise NotImplementedError(f"{self._class_name} does not support streaming")
|
|
753
|
+
|
|
754
|
+
def _create_streaming_df(
|
|
755
|
+
self, msg_key: MessageKey, msg: StreamingMessage
|
|
756
|
+
) -> nw.DataFrame[Any]:
|
|
757
|
+
raise NotImplementedError(f"{self._class_name} does not support streaming")
|
|
758
|
+
|
|
759
|
+
def _truncate_streaming_df(self, df: nw.DataFrame[Any]) -> nw.DataFrame[Any]:
|
|
760
|
+
assert self._control is not None, "control is not set"
|
|
761
|
+
max_data = self._control["max_data"]
|
|
762
|
+
if max_data and df.shape[0] > max_data:
|
|
763
|
+
df = df.tail(max_data)
|
|
764
|
+
return df
|
|
765
|
+
|
|
766
|
+
def _start_streaming(self):
|
|
767
|
+
if not self.is_streaming():
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
assert self._feed is not None, "feed is not set"
|
|
771
|
+
requests = cast("list[MarketFeedStreamRequest]", self._feed._requests)
|
|
772
|
+
assert all(request.is_streaming() for request in requests), (
|
|
773
|
+
"Not all requests in the streaming feed are for streaming"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
self._add_periodic_callback(self._refresh_streaming_ui)
|
|
777
|
+
|
|
778
|
+
for dataflows in self._feed._dataflows.values():
|
|
779
|
+
for dataflow in dataflows:
|
|
780
|
+
dataflow.add_default_transformations([self._on_streaming_callback])
|
|
781
|
+
|
|
782
|
+
if not self._feed.is_running():
|
|
783
|
+
if not self.is_in_notebook_mode():
|
|
784
|
+
self._streaming_thread = Thread(
|
|
785
|
+
target=self._feed.run,
|
|
786
|
+
daemon=True,
|
|
787
|
+
)
|
|
788
|
+
self._streaming_thread.start()
|
|
789
|
+
else:
|
|
790
|
+
asyncio.get_running_loop().create_task(self._feed.run_async())
|
|
791
|
+
|
|
792
|
+
# start streaming for overlays that have their own feeds
|
|
793
|
+
for overlay in self._overlays:
|
|
794
|
+
if overlay.is_streaming():
|
|
795
|
+
overlay._start_streaming()
|
|
796
|
+
|
|
797
|
+
def _refresh_streaming_ui(self):
|
|
798
|
+
"""during streaming, update pane and widgets accordingly using the newly updated data (updated in _on_streaming_callback)"""
|
|
799
|
+
if self._df is not None and self._is_streaming_ready():
|
|
800
|
+
self._update_pane(self._df)
|
|
801
|
+
self._update_widgets(self._df)
|
|
802
|
+
|
|
803
|
+
def _wait_for_streaming_ready(self):
|
|
804
|
+
if not self.is_streaming():
|
|
805
|
+
return
|
|
806
|
+
if self.is_streaming() and self._df is None:
|
|
807
|
+
while not self._is_streaming_ready():
|
|
808
|
+
cprint(
|
|
809
|
+
"Not enough data to plot, waiting for streaming data...",
|
|
810
|
+
style=TextStyle.BOLD + RichColor.YELLOW,
|
|
811
|
+
)
|
|
812
|
+
time.sleep(1)
|
|
813
|
+
for overlay in self._overlays:
|
|
814
|
+
if overlay.is_streaming():
|
|
815
|
+
overlay._wait_for_streaming_ready()
|
|
816
|
+
|
|
817
|
+
async def _wait_for_streaming_ready_async(self):
|
|
818
|
+
if self.is_streaming() and self._df is None:
|
|
819
|
+
while not self._is_streaming_ready():
|
|
820
|
+
cprint(
|
|
821
|
+
"Not enough data to plot, waiting for streaming data...",
|
|
822
|
+
style=TextStyle.BOLD + RichColor.YELLOW,
|
|
823
|
+
)
|
|
824
|
+
await asyncio.sleep(1)
|
|
825
|
+
|
|
826
|
+
def _render(self) -> RenderedResult:
|
|
827
|
+
self._create()
|
|
828
|
+
return self._renderer.render(self._component)
|
|
829
|
+
|
|
830
|
+
def _render_sync(self) -> RenderedResult:
|
|
831
|
+
if self.is_streaming():
|
|
832
|
+
self._start_streaming()
|
|
833
|
+
# NOTE: when streaming, need to wait for enough data before creating plot/pane etc.
|
|
834
|
+
# otherwise the e.g. x-axis might be wrong and cannot be fixed once it's served
|
|
835
|
+
self._wait_for_streaming_ready()
|
|
836
|
+
return self._render()
|
|
837
|
+
|
|
838
|
+
async def _render_async(self) -> RenderedResult:
|
|
839
|
+
if self.is_streaming():
|
|
840
|
+
self._start_streaming()
|
|
841
|
+
await self._wait_for_streaming_ready_async()
|
|
842
|
+
return self._render()
|
|
843
|
+
|
|
844
|
+
@classmethod
|
|
845
|
+
def set_style(cls, style: dict | None = None):
|
|
846
|
+
"""Set the class-level style for the plot."""
|
|
847
|
+
cls._style = cls._create_style(style, cls._backend, cls.style)
|
|
848
|
+
|
|
849
|
+
@staticmethod
|
|
850
|
+
def _create_style(
|
|
851
|
+
style: dict | None, backend: PlottingBackend, style_wrapper
|
|
852
|
+
) -> dict:
|
|
853
|
+
if style_wrapper is None:
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
default_style = getattr(style_wrapper, backend.value)()
|
|
857
|
+
|
|
858
|
+
if style is None:
|
|
859
|
+
return default_style
|
|
860
|
+
|
|
861
|
+
if not isinstance(style, dict):
|
|
862
|
+
raise ValueError("style must be a dictionary")
|
|
863
|
+
|
|
864
|
+
return {**default_style, **style}
|
|
865
|
+
|
|
866
|
+
def _set_style(self, style: Style | None = None):
|
|
867
|
+
"""Set the instance-level style for the plot."""
|
|
868
|
+
self._style = self._create_style(style, self._backend, self.style)
|
|
869
|
+
|
|
870
|
+
@classmethod
|
|
871
|
+
def set_control(cls, control: Control | None = None):
|
|
872
|
+
"""Set the class-level control for the plot."""
|
|
873
|
+
cls._control = cls._create_control(control, cls._backend, cls.control)
|
|
874
|
+
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _create_control(
|
|
877
|
+
control: Control | None, backend: PlottingBackend, control_wrapper
|
|
878
|
+
) -> Control | None:
|
|
879
|
+
if control_wrapper is None:
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
default_control = getattr(control_wrapper, backend.value)()
|
|
883
|
+
|
|
884
|
+
if control is None:
|
|
885
|
+
return default_control
|
|
886
|
+
|
|
887
|
+
if not isinstance(control, dict):
|
|
888
|
+
raise ValueError("control must be a dictionary")
|
|
889
|
+
|
|
890
|
+
return {**default_control, **control}
|
|
891
|
+
|
|
892
|
+
def _set_control(self, control: Control | None = None):
|
|
893
|
+
"""Set the instance-level control for the plot."""
|
|
894
|
+
self._control = self._create_control(control, self._backend, self.control)
|
|
895
|
+
|
|
896
|
+
@classmethod
|
|
897
|
+
def set_backend(cls, backend: PlottingBackend | str):
|
|
898
|
+
assert backend in cls.SUPPORTED_BACKENDS, (
|
|
899
|
+
f"Backend {backend} is not in supported backends: {cls.SUPPORTED_BACKENDS}"
|
|
900
|
+
)
|
|
901
|
+
original_backend = cls._backend
|
|
902
|
+
cls._backend = PlottingBackend[backend.lower()]
|
|
903
|
+
is_backend_changed = original_backend != cls._backend
|
|
904
|
+
# reset style and control if backend is changed
|
|
905
|
+
if is_backend_changed or cls._style is None:
|
|
906
|
+
cls.set_style()
|
|
907
|
+
if is_backend_changed or cls._control is None:
|
|
908
|
+
cls.set_control()
|
|
909
|
+
|
|
910
|
+
def _set_backend(self, backend: PlottingBackend | str):
|
|
911
|
+
"""Set the instance-level backend for the plot."""
|
|
912
|
+
assert backend in self.SUPPORTED_BACKENDS, (
|
|
913
|
+
f"Backend {backend} is not in supported backends: {self.SUPPORTED_BACKENDS}"
|
|
914
|
+
)
|
|
915
|
+
original_backend = self._backend
|
|
916
|
+
self._backend = PlottingBackend[backend.lower()]
|
|
917
|
+
is_backend_changed = original_backend != self._backend
|
|
918
|
+
# reset style and control if backend is changed
|
|
919
|
+
if is_backend_changed or self._style is None:
|
|
920
|
+
self._set_style()
|
|
921
|
+
if is_backend_changed or self._control is None:
|
|
922
|
+
self._set_control()
|
|
923
|
+
|
|
924
|
+
@classmethod
|
|
925
|
+
def set_mode(cls, mode: DisplayMode | str):
|
|
926
|
+
assert mode in DisplayMode.__members__, (
|
|
927
|
+
f"Mode {mode} is not in supported modes: {DisplayMode}"
|
|
928
|
+
)
|
|
929
|
+
cls._mode = DisplayMode[mode.lower()]
|
|
930
|
+
|
|
931
|
+
def _set_mode(self, mode: DisplayMode | str):
|
|
932
|
+
"""Set the instance-level mode for the plot."""
|
|
933
|
+
assert mode in DisplayMode.__members__, (
|
|
934
|
+
f"Mode {mode} is not in supported modes: {DisplayMode}"
|
|
935
|
+
)
|
|
936
|
+
self._mode = DisplayMode[mode.lower()]
|
|
937
|
+
self._set_renderer()
|
|
938
|
+
|
|
939
|
+
@classmethod
|
|
940
|
+
def remove_widgets(
|
|
941
|
+
cls, *WidgetClasses: type[BaseWidget | BaseStreamingWidget]
|
|
942
|
+
) -> None:
|
|
943
|
+
for WidgetClass in WidgetClasses:
|
|
944
|
+
if WidgetClass in cls._ChosenWidgetClasses:
|
|
945
|
+
cls._ChosenWidgetClasses.remove(WidgetClass)
|
|
946
|
+
if WidgetClass in cls._ChosenStreamingWidgetClasses:
|
|
947
|
+
cls._ChosenStreamingWidgetClasses.remove(WidgetClass)
|
|
948
|
+
|
|
949
|
+
def _remove_widgets(
|
|
950
|
+
self, *WidgetClasses: type[BaseWidget | BaseStreamingWidget]
|
|
951
|
+
) -> None:
|
|
952
|
+
for WidgetClass in WidgetClasses:
|
|
953
|
+
if WidgetClass in self._ChosenWidgetClasses:
|
|
954
|
+
self._ChosenWidgetClasses.remove(WidgetClass)
|
|
955
|
+
if WidgetClass in self._ChosenStreamingWidgetClasses:
|
|
956
|
+
self._ChosenStreamingWidgetClasses.remove(WidgetClass)
|
|
957
|
+
|
|
958
|
+
def _set_renderer(self):
|
|
959
|
+
if self._mode == DisplayMode.notebook:
|
|
960
|
+
from pfund_plot.renderers.notebook import NotebookRenderer
|
|
961
|
+
|
|
962
|
+
self._renderer = NotebookRenderer()
|
|
963
|
+
elif self._mode == DisplayMode.browser:
|
|
964
|
+
from pfund_plot.renderers.browser import BrowserRenderer
|
|
965
|
+
|
|
966
|
+
self._renderer = BrowserRenderer()
|
|
967
|
+
elif self._mode == DisplayMode.desktop:
|
|
968
|
+
from pfund_plot.renderers.desktop import DesktopRenderer
|
|
969
|
+
|
|
970
|
+
self._renderer = DesktopRenderer()
|
|
971
|
+
|
|
972
|
+
@property
|
|
973
|
+
def _plot_func(self) -> Callable[[nw.DataFrame[Any], Style, Control], Plot]:
|
|
974
|
+
"""Runs the plot function for the current backend."""
|
|
975
|
+
module_path = f"pfund_plot.plots.{self._class_name}.{self._backend}"
|
|
976
|
+
module = importlib.import_module(module_path)
|
|
977
|
+
return module.plot
|
|
978
|
+
|
|
979
|
+
@property
|
|
980
|
+
def figure(self) -> Figure:
|
|
981
|
+
import hvplot
|
|
982
|
+
|
|
983
|
+
if self._plot is None:
|
|
984
|
+
self._create_plot()
|
|
985
|
+
|
|
986
|
+
plot: Plot = self._plot
|
|
987
|
+
backend = self._backend
|
|
988
|
+
|
|
989
|
+
if backend == PlottingBackend.panel:
|
|
990
|
+
raise ValueError("Panel backend does not support figure property")
|
|
991
|
+
elif backend in [
|
|
992
|
+
PlottingBackend.bokeh,
|
|
993
|
+
PlottingBackend.plotly,
|
|
994
|
+
PlottingBackend.matplotlib,
|
|
995
|
+
]:
|
|
996
|
+
if self._is_hvplot(self._plot):
|
|
997
|
+
# use hvplot to convert holoviews Overlay to the underlying plotting library's figure
|
|
998
|
+
fig: dict = hvplot.render(plot, backend=backend)
|
|
999
|
+
if backend == PlottingBackend.plotly:
|
|
1000
|
+
import plotly.graph_objects as go
|
|
1001
|
+
|
|
1002
|
+
# hvplot.render() returns a dict, convert it to a plotly Figure
|
|
1003
|
+
fig = go.Figure(fig)
|
|
1004
|
+
else: # plot is from plt.bokeh(), plt.plotly() or plt.matplotlib()
|
|
1005
|
+
fig: RawFigure = plot
|
|
1006
|
+
else:
|
|
1007
|
+
raise ValueError(f"Unsupported backend: {backend}")
|
|
1008
|
+
return fig
|
|
1009
|
+
|
|
1010
|
+
def _is_using_marimo_svelte_combo(self):
|
|
1011
|
+
return (
|
|
1012
|
+
self._backend == PlottingBackend.svelte
|
|
1013
|
+
and self._mode == DisplayMode.notebook
|
|
1014
|
+
and self._notebook_type == NotebookType.marimo
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
@staticmethod
|
|
1018
|
+
def _get_sizing_mode(height: int | None, width: int | None) -> str | None:
|
|
1019
|
+
if height is None and width is None:
|
|
1020
|
+
return "stretch_both"
|
|
1021
|
+
elif height is None:
|
|
1022
|
+
return "stretch_height"
|
|
1023
|
+
elif width is None:
|
|
1024
|
+
return "stretch_width"
|
|
1025
|
+
else:
|
|
1026
|
+
return None
|
|
1027
|
+
|
|
1028
|
+
def _build_plot(self, df: nw.DataFrame[Any] | None = None) -> Plot:
|
|
1029
|
+
"""Returns a plot object for the given data, composing overlays and opts if any."""
|
|
1030
|
+
df = df if df is not None else self._df
|
|
1031
|
+
result = self._plot_func(
|
|
1032
|
+
df=df,
|
|
1033
|
+
x=self._x,
|
|
1034
|
+
y=self._y,
|
|
1035
|
+
style=self._style,
|
|
1036
|
+
control=self._control,
|
|
1037
|
+
**self._plot_kwargs,
|
|
1038
|
+
)
|
|
1039
|
+
if self._overlays:
|
|
1040
|
+
for overlay in self._overlays:
|
|
1041
|
+
overlay_plot = overlay._build_plot()
|
|
1042
|
+
if not self._is_hvplot(overlay_plot):
|
|
1043
|
+
raise TypeError("Operation '*' only supports Holoviews plots.")
|
|
1044
|
+
result = result * overlay_plot
|
|
1045
|
+
if self._holoviews_opts and self._is_hvplot(result):
|
|
1046
|
+
for args, kwargs in self._holoviews_opts:
|
|
1047
|
+
result = result.opts(*args, **kwargs)
|
|
1048
|
+
return result
|
|
1049
|
+
|
|
1050
|
+
def _create_plot(self):
|
|
1051
|
+
self._plot = self._build_plot(df=self._df)
|
|
1052
|
+
|
|
1053
|
+
def _create_pane(self):
|
|
1054
|
+
if (
|
|
1055
|
+
self._df is not None
|
|
1056
|
+
and self._control
|
|
1057
|
+
and self._control.get("num_data") is not None
|
|
1058
|
+
):
|
|
1059
|
+
df = self._df.tail(self._control["num_data"])
|
|
1060
|
+
else:
|
|
1061
|
+
df = self._df
|
|
1062
|
+
|
|
1063
|
+
if self._plot is None:
|
|
1064
|
+
self._create_plot()
|
|
1065
|
+
|
|
1066
|
+
backend = self._backend
|
|
1067
|
+
|
|
1068
|
+
if backend == PlottingBackend.panel:
|
|
1069
|
+
# no pane needed for panel backend (e.g. GridStack, use it directly as a component)
|
|
1070
|
+
pass
|
|
1071
|
+
elif backend == PlottingBackend.holoviews:
|
|
1072
|
+
self._pane = pn.pane.HoloViews(self._plot, **self._pane_kwargs)
|
|
1073
|
+
elif backend in [
|
|
1074
|
+
PlottingBackend.bokeh,
|
|
1075
|
+
PlottingBackend.plotly,
|
|
1076
|
+
PlottingBackend.matplotlib,
|
|
1077
|
+
PlottingBackend.altair,
|
|
1078
|
+
]:
|
|
1079
|
+
if self._is_hvplot(self._plot):
|
|
1080
|
+
from holoviews import DynamicMap
|
|
1081
|
+
from holoviews.streams import Pipe
|
|
1082
|
+
|
|
1083
|
+
self._streaming_pipe = Pipe(data=df)
|
|
1084
|
+
dmap = DynamicMap(
|
|
1085
|
+
lambda data: self._build_plot(df=data),
|
|
1086
|
+
streams=[self._streaming_pipe],
|
|
1087
|
+
)
|
|
1088
|
+
self._pane = pn.pane.HoloViews(
|
|
1089
|
+
dmap, linked_axes=self._control.get("linked_axes", True)
|
|
1090
|
+
)
|
|
1091
|
+
else:
|
|
1092
|
+
if backend == PlottingBackend.bokeh:
|
|
1093
|
+
self._pane = pn.pane.Bokeh(self._plot, **self._pane_kwargs)
|
|
1094
|
+
elif backend == PlottingBackend.plotly:
|
|
1095
|
+
self._pane = pn.pane.Plotly(self._plot, **self._pane_kwargs)
|
|
1096
|
+
elif backend == PlottingBackend.matplotlib:
|
|
1097
|
+
self._pane = pn.pane.Matplotlib(self._plot, **self._pane_kwargs)
|
|
1098
|
+
elif backend == PlottingBackend.altair:
|
|
1099
|
+
self._pane = pn.pane.Vega(self._plot, **self._pane_kwargs)
|
|
1100
|
+
elif backend == PlottingBackend.svelte:
|
|
1101
|
+
self._anywidget: AnyWidget = self._plot_func(df, self._style, self._control)
|
|
1102
|
+
self._pane = pn.pane.IPyWidget(self._anywidget)
|
|
1103
|
+
else:
|
|
1104
|
+
raise ValueError(f"Unsupported backend: {backend}")
|
|
1105
|
+
|
|
1106
|
+
def _is_overlay(self) -> bool:
|
|
1107
|
+
return self._parent_plot is not None
|
|
1108
|
+
|
|
1109
|
+
def _update_pane(self, df: nw.DataFrame[Any]):
|
|
1110
|
+
if self._is_overlay():
|
|
1111
|
+
self._update_df(df)
|
|
1112
|
+
assert self._parent_plot._streaming_pipe is not None, (
|
|
1113
|
+
"Overlay widgets require the base plot to be rendered via a HoloViews pipe."
|
|
1114
|
+
)
|
|
1115
|
+
# NOTE: the overlay has no rendered pane — it's composited inside the parent's DynamicMap.
|
|
1116
|
+
# Re-send the parent's current pipe data to trigger a DynamicMap re-render,
|
|
1117
|
+
# which will call _build_plot → overlay._build_plot() with the updated overlay._df.
|
|
1118
|
+
# This is not passing new data; it's just a re-render trigger.
|
|
1119
|
+
self._parent_plot._streaming_pipe.send(
|
|
1120
|
+
self._parent_plot._streaming_pipe.data
|
|
1121
|
+
)
|
|
1122
|
+
return
|
|
1123
|
+
if self._pane is None:
|
|
1124
|
+
self._create_pane()
|
|
1125
|
+
if self._backend == PlottingBackend.bokeh:
|
|
1126
|
+
self._streaming_pipe.send(df)
|
|
1127
|
+
elif self._backend == PlottingBackend.svelte:
|
|
1128
|
+
assert self._anywidget is not None, "anywidget is not set"
|
|
1129
|
+
self._anywidget.update_data(df)
|
|
1130
|
+
else:
|
|
1131
|
+
raise ValueError(f"Unsupported backend: {self._backend}")
|