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
@@ -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}")