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,116 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar, Literal
4
+
5
+ if TYPE_CHECKING:
6
+ from pfund_plot.typing import RawFigure
7
+
8
+ from pfund_kit.style import RichColor, TextStyle, cprint
9
+
10
+ from pfund_plot.enums import PlottingBackend
11
+ from pfund_plot.plots.lazy import LazyPlot
12
+ from pfund_plot.plots.plot import BasePlot
13
+
14
+
15
+ class BaseLayout(BasePlot):
16
+ SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.panel]]] = [
17
+ PlottingBackend.panel
18
+ ]
19
+ SUPPORT_STREAMING: ClassVar[bool] = True
20
+ REQUIRED_DATA: ClassVar[bool] = False
21
+
22
+ def __new__(cls, *plots: LazyPlot | RawFigure):
23
+ from pfund_plot.utils import convert_to_lazy_plot
24
+
25
+ # Convert any plotting library figures to LazyPlot instances
26
+ lazy_plots = tuple(
27
+ convert_to_lazy_plot(plot) if not isinstance(plot, LazyPlot) else plot
28
+ for plot in plots
29
+ )
30
+ # super().__new__ will call __init__ with converted_plots
31
+ return super().__new__(cls, *lazy_plots)
32
+
33
+ def __init__(self, *plots: LazyPlot): # pyright: ignore[reportInconsistentConstructor]
34
+ self._plots: tuple[LazyPlot, ...] = plots
35
+ super().__init__(data=None)
36
+
37
+ def _add_plots_periodic_callbacks(self):
38
+ """Transfer child plots' periodic callbacks to Layout's renderer.
39
+
40
+ Child plots inside Layout are never rendered directly — only Layout's
41
+ renderer gets render() called. Without this, child streaming plots'
42
+ periodic callbacks (e.g. _refresh_streaming_ui) would never start.
43
+ """
44
+ assert self._renderer is not None, "renderer is not set"
45
+ for lazyplot in self._plots:
46
+ plot = lazyplot._plot
47
+ assert plot._renderer is not None, f"{plot.name} renderer is not set"
48
+ for cb in plot._renderer._periodic_callbacks:
49
+ self._renderer.add_periodic_callback(cb)
50
+
51
+ def is_streaming(self):
52
+ return any(lazyplot.is_streaming for lazyplot in self._plots)
53
+
54
+ def _start_streaming(self):
55
+ for lazyplot in self._plots:
56
+ plot = lazyplot._plot
57
+ if plot.is_streaming():
58
+ plot._start_streaming()
59
+
60
+ def _wait_for_streaming_ready(self):
61
+ for lazyplot in self._plots:
62
+ plot = lazyplot._plot
63
+ if plot.is_streaming():
64
+ plot._wait_for_streaming_ready()
65
+
66
+ def _apply_linked_axes(self):
67
+ """Apply layout-level linked_axes control to all child plots."""
68
+ if self._control is None:
69
+ return
70
+ linked_axes = self._control.get("linked_axes", True)
71
+ for lazyplot in self._plots:
72
+ plot = lazyplot._plot
73
+ if plot._control is not None:
74
+ plot._control["linked_axes"] = linked_axes
75
+ for overlay in plot._overlays:
76
+ if overlay._control is not None:
77
+ overlay._control["linked_axes"] = linked_axes
78
+
79
+ def _warn_if_linked_axes_with_streaming(self):
80
+ """Warn if linked_axes is enabled when streaming and non-streaming plots coexist.
81
+
82
+ Streaming plots continuously update their x-axis range, which will drag
83
+ linked non-streaming plots to the same range — making their data invisible.
84
+ """
85
+ if self._control is None or not self._control.get("linked_axes", True):
86
+ return
87
+ has_streaming = any(lazyplot.is_streaming for lazyplot in self._plots)
88
+ has_non_streaming = any(not lazyplot.is_streaming for lazyplot in self._plots)
89
+ if has_streaming and has_non_streaming:
90
+ cprint(
91
+ "Streaming and non-streaming plots detected with linked_axes=True. "
92
+ + "Non-streaming plots may show empty data as their axes follow the streaming range. "
93
+ + "Use .control(linked_axes=False) to fix this.",
94
+ style=TextStyle.BOLD + RichColor.YELLOW,
95
+ )
96
+
97
+ def _create(self):
98
+ super()._create()
99
+ self._warn_if_linked_axes_with_streaming()
100
+
101
+ # no plot needed
102
+ def _create_plot(self):
103
+ pass
104
+
105
+ # no pane needed
106
+ def _create_pane(self):
107
+ pass
108
+
109
+ def _create_component(self):
110
+ self._apply_linked_axes()
111
+ self._component = self._plot_func(
112
+ *self._plots,
113
+ style=self._style,
114
+ control=self._control, # pyright: ignore[reportCallIssue]
115
+ )
116
+ self._add_plots_periodic_callbacks()
@@ -0,0 +1,51 @@
1
+ # pyright: reportUnusedParameter=false, reportArgumentType=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ from pfund_plot.plots.lazy import LazyPlot
8
+
9
+ import panel as pn
10
+ from panel.layout.gridstack import GridStack
11
+
12
+ __all__ = ["control", "plot", "style"]
13
+ DEFAULT_NUM_COLS = 3
14
+
15
+
16
+ def style():
17
+ return locals()
18
+
19
+
20
+ def control(
21
+ num_cols: int = DEFAULT_NUM_COLS,
22
+ allow_drag: bool = True,
23
+ allow_resize: bool = True,
24
+ linked_axes: bool = True,
25
+ ):
26
+ return locals()
27
+
28
+
29
+ def plot(
30
+ *plots: LazyPlot, style: dict[str, Any], control: dict[str, Any], **kwargs: Any
31
+ ) -> GridStack:
32
+ pn.extension("gridstack")
33
+
34
+ gstack = GridStack(
35
+ sizing_mode="stretch_both",
36
+ allow_drag=control["allow_drag"],
37
+ allow_resize=control["allow_resize"],
38
+ )
39
+
40
+ grid_specs = [plot._grid_spec for plot in plots]
41
+ if all(grid_spec is not None for grid_spec in grid_specs):
42
+ for plot, grid_spec in zip(plots, grid_specs, strict=True):
43
+ row_slice, col_slice = grid_spec
44
+ gstack[row_slice, col_slice] = plot.component
45
+ else:
46
+ num_plots = len(plots)
47
+ num_cols = control["num_cols"]
48
+ # num_rows = math.ceil(num_plots / num_cols)
49
+ for i in range(num_plots):
50
+ gstack[i // num_cols, i % num_cols] = plots[i].component
51
+ return gstack
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from panel import Tabs as PanelTabs
8
+
9
+ import importlib
10
+
11
+ from pfund_plot.plots.layout.layout import BaseLayout
12
+
13
+
14
+ class TabsStyle:
15
+ from pfund_plot.plots.layout.tabs.panel import style as panel_style
16
+
17
+ panel = panel_style
18
+
19
+
20
+ class TabsControl:
21
+ from pfund_plot.plots.layout.tabs.panel import control as panel_control
22
+
23
+ panel = panel_control
24
+
25
+
26
+ class Tabs(BaseLayout):
27
+ style = TabsStyle
28
+ control = TabsControl
29
+
30
+ # tabs is not at the top level of plots, it's inside layout/tabs, so we need to override the _plot property
31
+ @property
32
+ def _plot_func(self) -> Callable[..., PanelTabs]:
33
+ """Runs the plot function for the current backend."""
34
+ module_path: str = f"pfund_plot.plots.layout.{self._class_name}.{self._backend}"
35
+ module = importlib.import_module(module_path)
36
+ return module.plot
@@ -0,0 +1,51 @@
1
+ # pyright: reportUnusedParameter=false, reportArgumentType=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ from pfund_plot.plots.lazy import LazyPlot
8
+ from pfund_plot.typing import RawFigure
9
+
10
+ from panel import Tabs
11
+
12
+ __all__ = ["control", "plot", "style"]
13
+
14
+
15
+ def style(
16
+ height: int | None = None,
17
+ width: int | None = None,
18
+ ):
19
+ return locals()
20
+
21
+
22
+ def control(
23
+ dynamic: bool = False,
24
+ closable: bool = False,
25
+ position: Literal["above", "below", "left", "right"] = "above",
26
+ linked_axes: bool = True,
27
+ ):
28
+ """
29
+ Args:
30
+ dynamic: Dynamically populate only the active tab.
31
+ closable: Whether it should be possible to close tabs.
32
+ position: The location of the tabs relative to the tab contents.
33
+ linked_axes: Whether to link axes across plots in different tabs.
34
+ """
35
+ return locals()
36
+
37
+
38
+ def plot(
39
+ *plots: LazyPlot | RawFigure,
40
+ style: dict[str, Any],
41
+ control: dict[str, Any],
42
+ **kwargs: Any,
43
+ ) -> Tabs:
44
+ return Tabs(
45
+ *[plot.component for plot in plots],
46
+ height=style["height"],
47
+ width=style["width"],
48
+ dynamic=control["dynamic"],
49
+ closable=control["closable"],
50
+ tabs_location=control["position"],
51
+ )
@@ -0,0 +1,408 @@
1
+ # pyright: reportUnknownMemberType=false, reportAttributeAccessIssue=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any, Literal
5
+
6
+ if TYPE_CHECKING:
7
+ from narwhals.typing import IntoFrame
8
+ from panel.io.server import Server
9
+ from panel.pane import Pane
10
+ from panel.viewable import Viewable
11
+ from pfeed.feeds.market_feed import MarketFeed
12
+
13
+ from pfund_plot.enums import DisplayMode, PlottingBackend
14
+ from pfund_plot.plots.plot import BasePlot
15
+ from pfund_plot.typing import (
16
+ Component,
17
+ Figure,
18
+ Plot,
19
+ RenderedResult,
20
+ )
21
+ from pfund_plot.widgets.base import BaseWidget
22
+
23
+ import panel as pn
24
+
25
+
26
+ class LazyRow(pn.Row):
27
+ """A pn.Row subclass that supports + and | operators for chaining."""
28
+
29
+ def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
30
+ right = other.component if isinstance(other, LazyPlot) else other
31
+ return LazyRow(*self.objects, right)
32
+
33
+ def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
34
+ right = other.component if isinstance(other, LazyPlot) else other
35
+ return LazyColumn(self, right)
36
+
37
+
38
+ class LazyColumn(pn.Column):
39
+ """A pn.Column subclass that supports + and | operators for chaining."""
40
+
41
+ def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
42
+ right = other.component if isinstance(other, LazyPlot) else other
43
+ return LazyRow(self, right)
44
+
45
+ def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
46
+ right = other.component if isinstance(other, LazyPlot) else other
47
+ return LazyColumn(*self.objects, right)
48
+
49
+
50
+ class LazyPlot:
51
+ """Lazy plot builder that defers rendering until display.
52
+
53
+ Enables method chaining for configuring plots:
54
+ plt.ohlc(df).style(height=600, width=800).control(num_data=100)
55
+
56
+ Auto-renders when displayed in notebooks or at end of scripts.
57
+ """
58
+
59
+ def __init__(self, plot_instance: BasePlot):
60
+ self._plot = plot_instance
61
+ self._grid_spec: tuple[slice, slice] | None = None
62
+
63
+ def __getitem__(self, key: tuple[int | slice, int | slice]) -> LazyPlot:
64
+ """Set grid position for use with plt.layout (GridStack).
65
+
66
+ Args:
67
+ key: A tuple of two elements (row, col) specifying the grid position.
68
+ Each element can be an int (single position) or slice (range).
69
+ Examples: [1, 3], [1:2, 3:4], [1, 3:5]
70
+
71
+ Returns:
72
+ Self for method chaining
73
+
74
+ Example:
75
+ plt.ohlc(df)[0, 0] # single cell at row 0, col 0
76
+ plt.ohlc(df)[0:2, 0:6] # spans rows 0-1, cols 0-5
77
+ plt.ohlc(df)[1, 0:3] # row 1, cols 0-2
78
+ plt.layout(plot1[0:1, 0:2], plot2[0:1, 2:4])
79
+ """
80
+ if not isinstance(key, tuple) or len(key) != 2:
81
+ raise TypeError(
82
+ "Grid spec must be a tuple of two elements, e.g., [1, 3] or [1:2, 3:4]"
83
+ )
84
+ row_spec, col_spec = key
85
+ if not isinstance(row_spec, (int, slice)) or not isinstance(
86
+ col_spec, (int, slice)
87
+ ):
88
+ raise TypeError(
89
+ "Grid spec elements must be int or slice, e.g., [1, 3] or [1:2, 3:4]"
90
+ )
91
+
92
+ # Convert ints to slices for consistency
93
+ row_slice = (
94
+ slice(row_spec, row_spec + 1) if isinstance(row_spec, int) else row_spec
95
+ )
96
+ col_slice = (
97
+ slice(col_spec, col_spec + 1) if isinstance(col_spec, int) else col_spec
98
+ )
99
+
100
+ self._grid_spec = (row_slice, col_slice)
101
+ return self
102
+
103
+ @property
104
+ def name(self) -> str:
105
+ return self._plot.name
106
+
107
+ @property
108
+ def figure(self) -> Figure:
109
+ return self._plot.figure
110
+
111
+ @property
112
+ def plot(self) -> Plot:
113
+ if self._plot._plot is None:
114
+ self._plot._create_plot()
115
+ return self._plot._plot
116
+
117
+ @property
118
+ def is_streaming(self) -> bool:
119
+ return self._plot.is_streaming()
120
+
121
+ @property
122
+ def widgets(self) -> Any:
123
+ if not self._plot._widgets:
124
+ self._plot._create_widgets()
125
+ return self._plot._widgets
126
+
127
+ @property
128
+ def streaming_widgets(self) -> Any:
129
+ if self._plot.is_streaming() and not self._plot._streaming_widgets:
130
+ self._plot._create_widgets()
131
+ return self._plot._streaming_widgets
132
+
133
+ @property
134
+ def reactive_widgets(self) -> Any:
135
+ if self._plot._reactive_params and not self._plot._reactive_widgets:
136
+ self._plot._create_reactive_widgets()
137
+ return self._plot._reactive_widgets
138
+
139
+ def opts(self, *args: Any, **kwargs: Any) -> LazyPlot:
140
+ """Pass holoviews opts to the underlying plot.
141
+
142
+ Example:
143
+ candlestick.opts(multi_y=True)
144
+ (candlestick * volume).opts(multi_y=True)
145
+ """
146
+ self._plot._add_opts(args, kwargs)
147
+ return self
148
+
149
+ @property
150
+ def pane(self) -> Pane:
151
+ if self._plot._pane is None:
152
+ self._plot._create_pane()
153
+ return self._plot._pane
154
+
155
+ @property
156
+ def component(self) -> Component | None:
157
+ self._plot._create() # create pane+widgets+component
158
+ return self._plot._component
159
+
160
+ @property
161
+ def df(self) -> IntoFrame | None:
162
+ if self._plot._df is None:
163
+ return None
164
+ return self._plot._df.to_native()
165
+
166
+ @property
167
+ def feed(self) -> MarketFeed | None:
168
+ return self._plot._feed
169
+
170
+ def style(self, **kwargs) -> LazyPlot:
171
+ """Configure style options.
172
+
173
+ Args:
174
+ **kwargs: Style parameters (e.g., height, width, colors, etc.)
175
+
176
+ Returns:
177
+ Self for method chaining
178
+
179
+ Example:
180
+ plt.ohlc(df).style(height=600, width=800)
181
+ """
182
+ self._plot._set_style(kwargs)
183
+ return self
184
+
185
+ def get_style(self) -> dict:
186
+ return self._plot._style
187
+
188
+ def control(self, **kwargs) -> LazyPlot:
189
+ """Configure control options.
190
+
191
+ Args:
192
+ **kwargs: Control parameters (e.g., num_data, etc.)
193
+
194
+ Returns:
195
+ Self for method chaining
196
+
197
+ Example:
198
+ plt.ohlc(df).control(num_data=100)
199
+ """
200
+ self._plot._set_control(kwargs)
201
+ return self
202
+
203
+ def get_control(self) -> dict:
204
+ return self._plot._control
205
+
206
+ def backend(self, backend: PlottingBackend | str) -> LazyPlot:
207
+ """Override backend for this plot only.
208
+
209
+ Args:
210
+ backend: Backend to use ('bokeh' or 'svelte')
211
+
212
+ Returns:
213
+ Self for method chaining
214
+
215
+ Example:
216
+ plt.ohlc(df).backend('svelte').show()
217
+ """
218
+ self._plot._set_backend(backend)
219
+ return self
220
+
221
+ def get_backend(self) -> PlottingBackend:
222
+ return self._plot._backend
223
+
224
+ def mode(
225
+ self, mode: DisplayMode | Literal["notebook", "browser", "desktop"]
226
+ ) -> LazyPlot:
227
+ """Override display mode for this plot only.
228
+
229
+ Args:
230
+ mode: Display mode ('notebook', 'browser', or 'desktop')
231
+
232
+ Returns:
233
+ Self for method chaining
234
+
235
+ Example:
236
+ plt.ohlc(df).mode('browser').show()
237
+ """
238
+ self._plot._set_mode(mode)
239
+ return self
240
+
241
+ def get_mode(self) -> DisplayMode:
242
+ return self._plot._mode
243
+
244
+ def remove_widgets(self, *WidgetClasses: type[BaseWidget]) -> LazyPlot:
245
+ """Remove widgets from the plot.
246
+
247
+ Args:
248
+ *WidgetClasses: The widget classes to remove.
249
+
250
+ Returns:
251
+ Self for method chaining
252
+ """
253
+ self._plot._remove_widgets(*WidgetClasses)
254
+ return self
255
+
256
+ def _get_existing_server(self) -> Server | None:
257
+ renderer = self._plot._renderer
258
+ server = renderer.server
259
+ # server is already running, return it
260
+ # happens when in notebook envs, somehow _repr_mimebundle_ and _repr_html_ are called multiple times automatically
261
+ # e.g. plt.ohlc(df).mode('browser')
262
+ if server is not None:
263
+ return server
264
+ return None
265
+
266
+ def show(self) -> Server | RenderedResult:
267
+ """Explicitly render and display the plot.
268
+
269
+ Returns:
270
+ The rendered plot output
271
+ """
272
+ if self._plot.is_streaming() and self._plot.is_in_notebook_mode():
273
+ raise RuntimeError(
274
+ "Cannot render streaming plot in synchronous mode, use show_async() instead."
275
+ )
276
+ if server := self._get_existing_server():
277
+ return server
278
+ return self._plot._render_sync()
279
+
280
+ async def show_async(self) -> Server | RenderedResult:
281
+ if server := self._get_existing_server():
282
+ return server
283
+ return await self._plot._render_async()
284
+
285
+ def servable(self, title: str | None = None) -> Component:
286
+ """Mark the plot as servable for use with `panel serve` CLI.
287
+
288
+ Use this when running with: panel serve myfile.py --show --autoreload
289
+
290
+ Args:
291
+ title: Optional title for the browser tab
292
+
293
+ Returns:
294
+ The servable component
295
+
296
+ Example:
297
+ fig = plt.ohlc(df).backend('bokeh')
298
+ fig.servable()
299
+ """
300
+ return self.component.servable(title=title)
301
+
302
+ def _repr_mimebundle_(self, include=None, exclude=None) -> dict[str, Any]:
303
+ """Auto-render in Jupyter/IPython notebooks.
304
+
305
+ This magic method is called when the object is the last expression
306
+ in a notebook cell, enabling auto-display without explicit render().
307
+ """
308
+ result = self.show()
309
+ if hasattr(result, "_repr_mimebundle_"):
310
+ return result._repr_mimebundle_(include, exclude)
311
+ return {}
312
+
313
+ def _repr_html_(self) -> str | None:
314
+ """Fallback HTML representation for notebooks."""
315
+ result = self.show()
316
+ if hasattr(result, "_repr_html_"):
317
+ return result._repr_html_()
318
+ return None
319
+
320
+ def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
321
+ """Combine plots horizontally using + operator.
322
+
323
+ Args:
324
+ other: Another LazyPlot, Panel component, or any Panel-compatible object
325
+
326
+ Returns:
327
+ LazyRow containing the combined components
328
+
329
+ Example:
330
+ plot1 + plot2 # Side by side
331
+ plot1 + plot2 + plot3 # Three plots in a row
332
+ plot1 + some_widget # LazyPlot with a widget
333
+ """
334
+ left = self.component
335
+ right = other.component if isinstance(other, LazyPlot) else other
336
+ return LazyRow(left, right)
337
+
338
+ def __radd__(self, other: Viewable) -> LazyRow:
339
+ """Support reverse addition (when left operand doesn't support +)."""
340
+ return LazyRow(other, self.component)
341
+
342
+ def __mul__(self, other: LazyPlot) -> LazyPlot:
343
+ """Overlay another plot or overlay layer onto this plot using * operator.
344
+
345
+ Returns a new LazyPlot with the overlay appended, leaving the original unchanged.
346
+ This allows reuse: plot * marker1 and plot * marker2 are independent.
347
+
348
+ Args:
349
+ other: A LazyPlot to composite onto this plot
350
+
351
+ Returns:
352
+ A new LazyPlot with the overlay added
353
+
354
+ Example:
355
+ candlestick * markers # overlay markers on candlestick
356
+ candlestick * volume_bars # overlay volume on candlestick
357
+ """
358
+ from copy import deepcopy
359
+
360
+ if not isinstance(other, LazyPlot):
361
+ raise TypeError(f"Cannot overlay {type(other).__name__} onto a plot.")
362
+
363
+ other_plot = other._plot
364
+ current_plot = self._plot
365
+ if other_plot.is_streaming() != current_plot.is_streaming():
366
+ raise RuntimeError(
367
+ "Cannot overlay a streaming plot with a non-streaming plot."
368
+ )
369
+ if other_plot._backend != current_plot._backend:
370
+ raise RuntimeError("Cannot overlay plots with different backends.")
371
+ if other_plot._mode != current_plot._mode:
372
+ raise RuntimeError("Cannot overlay plots with different modes.")
373
+ # NOTE: deepcopy both plots so the originals are not mutated —
374
+ # conceptually the result is a new plot instance that carries its own _overlays list.
375
+ # The overlay must also be cloned so that _parent_plot doesn't get shared
376
+ # when the same overlay is reused across multiple compositions.
377
+ try:
378
+ cloned_plot = deepcopy(self._plot)
379
+ cloned_plot._add_overlay(deepcopy(other._plot))
380
+ except (RuntimeError, AttributeError, TypeError) as e:
381
+ raise RuntimeError(
382
+ "Cannot overlay a plot that has already been rendered. "
383
+ + "Bokeh objects cannot be copied after rendering. "
384
+ + "Create fresh plots for the overlay instead, e.g. plt.ohlc(df) * plt.label(df, ...)"
385
+ ) from e
386
+ return LazyPlot(cloned_plot)
387
+
388
+ def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
389
+ """Stack plots vertically using | operator.
390
+
391
+ Args:
392
+ other: Another LazyPlot, Panel component, or any Panel-compatible object
393
+
394
+ Returns:
395
+ LazyColumn containing the stacked components
396
+
397
+ Example:
398
+ plot1 | plot2 # Vertically stacked
399
+ plot1 | plot2 | plot3 # Three plots stacked
400
+ plot1 | "Some text label" # LazyPlot with text below
401
+ """
402
+ top = self.component
403
+ bottom = other.component if isinstance(other, LazyPlot) else other
404
+ return LazyColumn(top, bottom)
405
+
406
+ def __ror__(self, other: Viewable) -> LazyColumn:
407
+ """Support reverse or (when left operand doesn't support |)."""
408
+ return LazyColumn(other, self.component)
@@ -0,0 +1,37 @@
1
+ # pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, ClassVar
5
+
6
+ if TYPE_CHECKING:
7
+ from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
8
+
9
+ from pfund_plot.enums import PlottingBackend
10
+ from pfund_plot.plots.plot import BasePlot
11
+ from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
12
+ from pfund_plot.widgets.ticker_widget import TickerSelectWidget
13
+
14
+ __all__ = ["Line"]
15
+
16
+
17
+ class LineStyle:
18
+ from pfund_plot.plots.line.bokeh import style as bokeh_style
19
+
20
+ bokeh = bokeh_style
21
+
22
+
23
+ class LineControl:
24
+ from pfund_plot.plots.line.bokeh import control as bokeh_control
25
+
26
+ bokeh = bokeh_control
27
+
28
+
29
+ class Line(BasePlot):
30
+ SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
31
+ SUPPORT_STREAMING: ClassVar[bool] = True
32
+ SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
33
+ SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]]] = [
34
+ TickerSelectWidget
35
+ ]
36
+ style = LineStyle
37
+ control = LineControl