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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from plotly.graph_objects import Figure as PlotlyFigure
|
|
7
|
+
|
|
8
|
+
from pfund_plot.enums import PlottingBackend
|
|
9
|
+
from pfund_plot.plots.plot import BasePlot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Plotly(BasePlot):
|
|
13
|
+
SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.plotly]]] = [
|
|
14
|
+
PlottingBackend.plotly
|
|
15
|
+
]
|
|
16
|
+
REQUIRED_DATA: ClassVar[bool] = False
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, fig: PlotlyFigure, sizing_mode: str | None = None, **pane_kwargs: Any
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Args:
|
|
23
|
+
fig: A Plotly Figure object.
|
|
24
|
+
sizing_mode: Panel sizing mode: e.g. "stretch_width", "stretch_height", or "stretch_both".
|
|
25
|
+
**pane_kwargs: Additional keyword arguments passed to pn.pane.Plotly,
|
|
26
|
+
e.g. height, width, max_width, margin.
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(data=None)
|
|
29
|
+
self._plot: PlotlyFigure = fig
|
|
30
|
+
self._pane_kwargs = pane_kwargs
|
|
31
|
+
if sizing_mode is not None:
|
|
32
|
+
self._pane_kwargs["sizing_mode"] = sizing_mode
|
|
@@ -0,0 +1,62 @@
|
|
|
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, Any, ClassVar
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from narwhals.typing import IntoFrame
|
|
8
|
+
|
|
9
|
+
from pfund_plot.widgets.base import BaseWidget
|
|
10
|
+
|
|
11
|
+
from pfund_plot.enums import PlottingBackend
|
|
12
|
+
from pfund_plot.plots.plot import BasePlot
|
|
13
|
+
from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
|
|
14
|
+
|
|
15
|
+
__all__ = ["Scatter"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScatterStyle:
|
|
19
|
+
from pfund_plot.plots.scatter.bokeh import style as bokeh_style
|
|
20
|
+
|
|
21
|
+
bokeh = bokeh_style
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScatterControl:
|
|
25
|
+
from pfund_plot.plots.scatter.bokeh import control as bokeh_control
|
|
26
|
+
|
|
27
|
+
bokeh = bokeh_control
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Scatter(BasePlot):
|
|
31
|
+
"""Generic scatter plot — renders points on a 2D plane.
|
|
32
|
+
|
|
33
|
+
Supports per-point styling via DataFrame columns or uniform styling via kwargs.
|
|
34
|
+
Works standalone or composed onto another plot via the * operator.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
data: DataFrame with point positions
|
|
38
|
+
x: Column name for x-axis position
|
|
39
|
+
y: Column name for y-axis position
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
plt.scatter(df, x='x', y='y')
|
|
43
|
+
plt.ohlc(ohlc_df) * plt.scatter(df, x='date', y='price')
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
|
|
47
|
+
# TODO: support other streaming feeds like EngineFeed etc.
|
|
48
|
+
# SUPPORT_STREAMING: ClassVar[bool] = True
|
|
49
|
+
SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
|
|
50
|
+
style = ScatterStyle
|
|
51
|
+
control = ScatterControl
|
|
52
|
+
|
|
53
|
+
# TODO: add "by" parameter to group by, see https://hvplot.holoviz.org/en/docs/latest/ref/api/manual/hvplot.hvPlot.scatter.html
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
data: IntoFrame,
|
|
57
|
+
x: str | None = None,
|
|
58
|
+
y: str | list[str] | None = None,
|
|
59
|
+
name: str | None = None,
|
|
60
|
+
**reactive_params: Any,
|
|
61
|
+
):
|
|
62
|
+
super().__init__(data=data, x=x, y=y, name=name, **reactive_params)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# pyright: reportUnusedParameter=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from holoviews.core.overlay import Overlay
|
|
8
|
+
|
|
9
|
+
import narwhals as nw
|
|
10
|
+
|
|
11
|
+
from pfund_plot.enums import PlottingBackend
|
|
12
|
+
|
|
13
|
+
__all__ = ["control", "plot", "style"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Marker mapping from user-friendly names to Bokeh scatter types
|
|
17
|
+
MARKER_MAP = {
|
|
18
|
+
"triangle_up": "triangle",
|
|
19
|
+
"triangle_down": "inverted_triangle",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DEFAULT_MARKER = "circle"
|
|
23
|
+
DEFAULT_SIZE = 100 # hvplot takes sqrt(size), so 100 -> rendered size of 10
|
|
24
|
+
DEFAULT_COLOR = "steelblue"
|
|
25
|
+
DEFAULT_HEIGHT = 280
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def style(
|
|
29
|
+
title: str = "Scatter",
|
|
30
|
+
xlabel: str = "",
|
|
31
|
+
ylabel: str = "",
|
|
32
|
+
color: str = DEFAULT_COLOR,
|
|
33
|
+
marker: Literal[
|
|
34
|
+
"circle",
|
|
35
|
+
"square",
|
|
36
|
+
"triangle_up",
|
|
37
|
+
"triangle_down",
|
|
38
|
+
"diamond",
|
|
39
|
+
"cross",
|
|
40
|
+
"x",
|
|
41
|
+
"star",
|
|
42
|
+
]
|
|
43
|
+
| str = DEFAULT_MARKER,
|
|
44
|
+
size: int | str = DEFAULT_SIZE,
|
|
45
|
+
opacity: float = 0.8,
|
|
46
|
+
grid: bool = False,
|
|
47
|
+
total_height: int | None = None,
|
|
48
|
+
height: int = DEFAULT_HEIGHT,
|
|
49
|
+
width: int | None = None,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Args:
|
|
53
|
+
title: the title of the plot
|
|
54
|
+
xlabel: the label of the x-axis
|
|
55
|
+
ylabel: the label of the y-axis
|
|
56
|
+
color: Column name for per-point color, or a literal color string
|
|
57
|
+
marker: Column name for per-point marker, or a literal marker string.
|
|
58
|
+
Supported markers: circle, square, triangle_up, triangle_down,
|
|
59
|
+
diamond, cross, x, star
|
|
60
|
+
size: Point size. Either a fixed int (e.g. size=15) for uniform sizing,
|
|
61
|
+
or a column name (e.g. size='volume') for per-point sizing from data.
|
|
62
|
+
opacity: scatter opacity (0.0 to 1.0)
|
|
63
|
+
grid: whether to show the grid
|
|
64
|
+
total_height: the height of the component (including the figure + widgets).
|
|
65
|
+
Default is None, Panel will automatically adjust its height.
|
|
66
|
+
height: the height of the figure
|
|
67
|
+
width: the width of the plot, since the plot is responsive, this is only used in panel layout
|
|
68
|
+
"""
|
|
69
|
+
return locals()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def control(
|
|
73
|
+
num_data: int | None = None,
|
|
74
|
+
slider_step: int | None = None,
|
|
75
|
+
widgets: bool = True,
|
|
76
|
+
linked_axes: bool = True,
|
|
77
|
+
include_extra_cols: bool = False,
|
|
78
|
+
):
|
|
79
|
+
"""
|
|
80
|
+
Args:
|
|
81
|
+
num_data: (DatetimeRangeWidget) initial number of most recent data points to display.
|
|
82
|
+
slider_step: (DatetimeRangeWidget) step size in ms for the datetime range slider.
|
|
83
|
+
If None, derived from data resolution.
|
|
84
|
+
widgets: whether to show widgets. default is True.
|
|
85
|
+
For granular control, use remove_widgets() to remove specific widget classes.
|
|
86
|
+
linked_axes: whether to link axes across plots in a layout (plt.layout(...)).
|
|
87
|
+
include_extra_cols: whether to include extra columns in the hover tooltip.
|
|
88
|
+
"""
|
|
89
|
+
return locals()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def plot(
|
|
93
|
+
df: nw.DataFrame[Any],
|
|
94
|
+
style: dict[str, Any],
|
|
95
|
+
control: dict[str, Any],
|
|
96
|
+
x: str | None = None,
|
|
97
|
+
y: str | list[str] | None = None,
|
|
98
|
+
**kwargs: Any,
|
|
99
|
+
) -> Overlay:
|
|
100
|
+
import hvplot
|
|
101
|
+
|
|
102
|
+
_ = hvplot.extension(PlottingBackend.bokeh)
|
|
103
|
+
|
|
104
|
+
color = style["color"]
|
|
105
|
+
marker = style["marker"]
|
|
106
|
+
size = style["size"]
|
|
107
|
+
|
|
108
|
+
columns = df.columns
|
|
109
|
+
|
|
110
|
+
if control["include_extra_cols"]:
|
|
111
|
+
exclude: set[str] = set()
|
|
112
|
+
if x:
|
|
113
|
+
exclude.add(x)
|
|
114
|
+
if isinstance(y, list):
|
|
115
|
+
exclude.update(y)
|
|
116
|
+
elif y:
|
|
117
|
+
exclude.add(y)
|
|
118
|
+
kwargs["hover_cols"] = [c for c in columns if c not in exclude]
|
|
119
|
+
|
|
120
|
+
color_is_col = isinstance(color, str) and color in columns
|
|
121
|
+
size_is_col = isinstance(size, str) and size in columns
|
|
122
|
+
marker_is_col = isinstance(marker, str) and marker in columns
|
|
123
|
+
|
|
124
|
+
if color_is_col:
|
|
125
|
+
kwargs["c"] = color
|
|
126
|
+
else:
|
|
127
|
+
kwargs["color"] = color
|
|
128
|
+
|
|
129
|
+
if size_is_col:
|
|
130
|
+
kwargs["s"] = size
|
|
131
|
+
else:
|
|
132
|
+
kwargs["size"] = size
|
|
133
|
+
|
|
134
|
+
if marker_is_col:
|
|
135
|
+
df = df.with_columns(
|
|
136
|
+
nw.col(marker).replace_strict(MARKER_MAP, default=nw.col(marker))
|
|
137
|
+
)
|
|
138
|
+
kwargs["marker"] = marker
|
|
139
|
+
else:
|
|
140
|
+
kwargs["marker"] = MARKER_MAP.get(marker, marker)
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
df.to_native()
|
|
144
|
+
.hvplot.scatter(
|
|
145
|
+
x=x,
|
|
146
|
+
y=y,
|
|
147
|
+
responsive=True,
|
|
148
|
+
alpha=style["opacity"],
|
|
149
|
+
grid=style["grid"],
|
|
150
|
+
**kwargs,
|
|
151
|
+
)
|
|
152
|
+
.opts(
|
|
153
|
+
title=style["title"],
|
|
154
|
+
xlabel=style["xlabel"],
|
|
155
|
+
ylabel=style["ylabel"],
|
|
156
|
+
height=style["height"],
|
|
157
|
+
)
|
|
158
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from narwhals.typing import IntoFrame
|
|
9
|
+
|
|
10
|
+
from pfund_plot.typing import Control, Plot, Style
|
|
11
|
+
|
|
12
|
+
import narwhals as nw
|
|
13
|
+
|
|
14
|
+
from pfund_plot.plots.scatter import Scatter
|
|
15
|
+
|
|
16
|
+
__all__ = ["Marker"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Marker(Scatter):
|
|
20
|
+
"""Marker plot that colors points by signal direction.
|
|
21
|
+
|
|
22
|
+
Positive signal values (>= 0) get one color/marker, negative values get another.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
data: DataFrame with marker positions
|
|
26
|
+
x: Column name for x-axis position
|
|
27
|
+
y: Column name for y-axis position (e.g. 'close' price)
|
|
28
|
+
signal: Column whose sign determines positive/negative styling.
|
|
29
|
+
If None, defaults to the y column.
|
|
30
|
+
pos_color: Color for positive values (default: 'green')
|
|
31
|
+
neg_color: Color for negative values (default: 'red')
|
|
32
|
+
pos_marker: Marker shape for positive values (default: 'triangle_up')
|
|
33
|
+
neg_marker: Marker shape for negative values (default: 'triangle_down')
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
plt.marker(df, x='date', y='pnl')
|
|
37
|
+
plt.ohlc(df) * plt.marker(df, x='date', y='close', signal='trade')
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
data: IntoFrame,
|
|
43
|
+
x: str | None = None,
|
|
44
|
+
y: str | list[str] | None = None,
|
|
45
|
+
signal: str | None = None,
|
|
46
|
+
pos_color: str = "green",
|
|
47
|
+
neg_color: str = "red",
|
|
48
|
+
pos_marker: Literal[
|
|
49
|
+
"circle",
|
|
50
|
+
"square",
|
|
51
|
+
"triangle_up",
|
|
52
|
+
"triangle_down",
|
|
53
|
+
"diamond",
|
|
54
|
+
"cross",
|
|
55
|
+
"x",
|
|
56
|
+
"star",
|
|
57
|
+
] = "triangle_up",
|
|
58
|
+
neg_marker: Literal[
|
|
59
|
+
"circle",
|
|
60
|
+
"square",
|
|
61
|
+
"triangle_up",
|
|
62
|
+
"triangle_down",
|
|
63
|
+
"diamond",
|
|
64
|
+
"cross",
|
|
65
|
+
"x",
|
|
66
|
+
"star",
|
|
67
|
+
] = "triangle_down",
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
**reactive_params: Any,
|
|
70
|
+
):
|
|
71
|
+
self._signal = signal
|
|
72
|
+
self._pos_color = pos_color
|
|
73
|
+
self._neg_color = neg_color
|
|
74
|
+
self._pos_marker = pos_marker
|
|
75
|
+
self._neg_marker = neg_marker
|
|
76
|
+
super().__init__(data=data, x=x, y=y, name=name, **reactive_params)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def _plot_func(self) -> Callable[[nw.DataFrame[Any], Style, Control], Plot]:
|
|
80
|
+
"""Runs the plot function for the current backend."""
|
|
81
|
+
import importlib
|
|
82
|
+
|
|
83
|
+
module_path = f"pfund_plot.plots.scatter.{self._backend}"
|
|
84
|
+
module = importlib.import_module(module_path)
|
|
85
|
+
return module.plot
|
|
86
|
+
|
|
87
|
+
def _standardize_df(self, df: IntoFrame) -> nw.DataFrame[Any]:
|
|
88
|
+
df: nw.DataFrame[Any] = super()._standardize_df(df)
|
|
89
|
+
signal_col = self._signal if self._signal else self._y
|
|
90
|
+
is_positive = nw.col(signal_col) >= 0
|
|
91
|
+
df = df.with_columns(
|
|
92
|
+
nw.when(is_positive)
|
|
93
|
+
.then(nw.lit(self._pos_color))
|
|
94
|
+
.otherwise(nw.lit(self._neg_color))
|
|
95
|
+
.alias("_color"),
|
|
96
|
+
nw.when(is_positive)
|
|
97
|
+
.then(nw.lit(self._pos_marker))
|
|
98
|
+
.otherwise(nw.lit(self._neg_marker))
|
|
99
|
+
.alias("_marker"),
|
|
100
|
+
)
|
|
101
|
+
return df
|
|
102
|
+
|
|
103
|
+
def _set_style(self, style: dict[str, Any] | None = None):
|
|
104
|
+
super()._set_style(style)
|
|
105
|
+
if self._style is not None:
|
|
106
|
+
self._style["color"] = "_color"
|
|
107
|
+
self._style["marker"] = "_marker"
|
pfund_plot/plots/ta.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from panel.io.callbacks import PeriodicCallback
|
|
8
|
+
from panel.io.server import Server
|
|
9
|
+
from panel.io.threads import StoppableThread
|
|
10
|
+
|
|
11
|
+
from pfund_plot.enums import NotebookType
|
|
12
|
+
from pfund_plot.typing import Component, RenderedResult
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
import panel as pn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseRenderer(ABC):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
from pfund_kit.utils import get_notebook_type
|
|
22
|
+
|
|
23
|
+
self._periodic_callbacks: list[PeriodicCallback] = []
|
|
24
|
+
self._port: int | None = None
|
|
25
|
+
self._server: StoppableThread | Server | None = None
|
|
26
|
+
self._notebook_type: NotebookType | None = get_notebook_type()
|
|
27
|
+
|
|
28
|
+
def is_in_notebook_env(self) -> bool:
|
|
29
|
+
return self._notebook_type is not None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def server(self) -> StoppableThread | Server | None:
|
|
33
|
+
return self._server
|
|
34
|
+
|
|
35
|
+
def add_periodic_callback(self, periodic_callback: PeriodicCallback):
|
|
36
|
+
from panel.io.callbacks import PeriodicCallback
|
|
37
|
+
|
|
38
|
+
if not isinstance(periodic_callback, PeriodicCallback):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"periodic_callback must be a panel.io.callbacks.PeriodicCallback instance"
|
|
41
|
+
)
|
|
42
|
+
self._periodic_callbacks.append(periodic_callback)
|
|
43
|
+
|
|
44
|
+
def run_periodic_callbacks(self):
|
|
45
|
+
for periodic_callback in self._periodic_callbacks:
|
|
46
|
+
periodic_callback.start()
|
|
47
|
+
|
|
48
|
+
def set_port_in_use(self, port: int):
|
|
49
|
+
self._port = port
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _get_free_port() -> int:
|
|
53
|
+
from pfund_kit.utils import get_free_port
|
|
54
|
+
|
|
55
|
+
return get_free_port()
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def render(self, component: Component, *args: Any, **kwargs: Any) -> RenderedResult:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def serve(
|
|
62
|
+
self,
|
|
63
|
+
# component or factory function that returns a component
|
|
64
|
+
renderable: Component | Callable[[], Component],
|
|
65
|
+
show: bool = False,
|
|
66
|
+
threaded: bool = True,
|
|
67
|
+
port: int | None = None,
|
|
68
|
+
) -> StoppableThread | Server:
|
|
69
|
+
from pfund_plot.config import get_config
|
|
70
|
+
|
|
71
|
+
if port is None:
|
|
72
|
+
port = self._get_free_port()
|
|
73
|
+
if self._server is not None:
|
|
74
|
+
raise ValueError("Server is already running")
|
|
75
|
+
config = get_config()
|
|
76
|
+
self.set_port_in_use(port)
|
|
77
|
+
self._server = pn.serve( # pyright: ignore[reportUnknownMemberType]
|
|
78
|
+
renderable, # pyright: ignore[reportArgumentType]
|
|
79
|
+
show=show,
|
|
80
|
+
threaded=threaded,
|
|
81
|
+
port=port,
|
|
82
|
+
static_dirs=config.static_dirs,
|
|
83
|
+
)
|
|
84
|
+
return self._server
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from panel.io.threads import StoppableThread
|
|
7
|
+
|
|
8
|
+
from pfund_plot.typing import Component
|
|
9
|
+
|
|
10
|
+
from pfund_plot.renderers.base import BaseRenderer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrowserRenderer(BaseRenderer):
|
|
14
|
+
def render(self, component: Component):
|
|
15
|
+
if self.is_in_notebook_env(): # run browser mode in a notebook environment
|
|
16
|
+
thread = cast(
|
|
17
|
+
"StoppableThread", self.serve(component, show=True, threaded=True)
|
|
18
|
+
)
|
|
19
|
+
self.run_periodic_callbacks()
|
|
20
|
+
return thread
|
|
21
|
+
else: # run in a python script
|
|
22
|
+
# wrap component in a factory so periodic callbacks start inside the server's event loop
|
|
23
|
+
def _servable():
|
|
24
|
+
self.run_periodic_callbacks()
|
|
25
|
+
return component
|
|
26
|
+
|
|
27
|
+
# this will block the main thread
|
|
28
|
+
_ = self.serve(_servable, show=True, threaded=False)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# pyright: reportUnknownMemberType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from panel.io.threads import StoppableThread
|
|
8
|
+
|
|
9
|
+
from pfund_plot.typing import Component
|
|
10
|
+
|
|
11
|
+
from multiprocessing import Event as create_event
|
|
12
|
+
from multiprocessing import Process
|
|
13
|
+
from multiprocessing.synchronize import Event
|
|
14
|
+
from threading import Thread
|
|
15
|
+
|
|
16
|
+
from pfund_kit.style import RichColor, TextStyle, cprint
|
|
17
|
+
|
|
18
|
+
from pfund_plot.renderers.browser import BrowserRenderer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_webview(title: str, port: int, window_ready: Event):
|
|
22
|
+
import webview as wv
|
|
23
|
+
|
|
24
|
+
_ = wv.create_window(
|
|
25
|
+
title,
|
|
26
|
+
url=f"http://localhost:{port}",
|
|
27
|
+
resizable=True,
|
|
28
|
+
)
|
|
29
|
+
window_ready.set()
|
|
30
|
+
wv.start()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DesktopRenderer(BrowserRenderer):
|
|
34
|
+
def render(self, component: Component):
|
|
35
|
+
port = self._get_free_port()
|
|
36
|
+
title = getattr(component, "name", "PFund Plot")
|
|
37
|
+
window_ready = create_event()
|
|
38
|
+
if self.is_in_notebook_env():
|
|
39
|
+
server = cast(
|
|
40
|
+
"StoppableThread",
|
|
41
|
+
self.serve(component, show=False, threaded=True, port=port),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def run_process():
|
|
45
|
+
try:
|
|
46
|
+
process = Process(
|
|
47
|
+
target=_run_webview,
|
|
48
|
+
name=title,
|
|
49
|
+
args=(
|
|
50
|
+
title,
|
|
51
|
+
port,
|
|
52
|
+
window_ready,
|
|
53
|
+
),
|
|
54
|
+
daemon=True,
|
|
55
|
+
)
|
|
56
|
+
process.start()
|
|
57
|
+
process.join()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
cprint(
|
|
60
|
+
f"An error occurred: {e}", style=TextStyle.BOLD + RichColor.RED
|
|
61
|
+
)
|
|
62
|
+
finally:
|
|
63
|
+
server.stop()
|
|
64
|
+
|
|
65
|
+
thread = Thread(target=run_process, daemon=True)
|
|
66
|
+
thread.start()
|
|
67
|
+
# wait for the window to be ready before starting the periodic callback to prevent data loss during streaming
|
|
68
|
+
_ = window_ready.wait()
|
|
69
|
+
self.run_periodic_callbacks()
|
|
70
|
+
return server
|
|
71
|
+
else:
|
|
72
|
+
process = Process(
|
|
73
|
+
target=_run_webview,
|
|
74
|
+
name=title,
|
|
75
|
+
args=(
|
|
76
|
+
title,
|
|
77
|
+
port,
|
|
78
|
+
window_ready,
|
|
79
|
+
),
|
|
80
|
+
daemon=True,
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
process.start()
|
|
84
|
+
except RuntimeError as e:
|
|
85
|
+
if "freeze_support" in str(
|
|
86
|
+
e
|
|
87
|
+
) or "current process has finished its bootstrapping phase" in str(e):
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"Failed to start desktop renderer process.\n"
|
|
90
|
+
+ "Please wrap your code with:\n\n"
|
|
91
|
+
+ " if __name__ == '__main__':\n"
|
|
92
|
+
+ " # your code here\n"
|
|
93
|
+
) from None
|
|
94
|
+
raise
|
|
95
|
+
_ = window_ready.wait()
|
|
96
|
+
|
|
97
|
+
def _servable():
|
|
98
|
+
self.run_periodic_callbacks()
|
|
99
|
+
return component
|
|
100
|
+
|
|
101
|
+
thread = Thread(
|
|
102
|
+
target=lambda: self.serve(
|
|
103
|
+
_servable, show=False, threaded=False, port=port
|
|
104
|
+
),
|
|
105
|
+
daemon=True,
|
|
106
|
+
)
|
|
107
|
+
thread.start()
|
|
108
|
+
|
|
109
|
+
process.join()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# pyright: reportUnknownMemberType=false, reportOptionalMemberAccess=false, reportCallIssue=false, reportUnusedVariable=false, reportOperatorIssue=false, reportUnknownArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pfund_plot.typing import Component
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import marimo as mo
|
|
11
|
+
except ImportError:
|
|
12
|
+
mo = None
|
|
13
|
+
|
|
14
|
+
from pfund_plot.enums import NotebookType
|
|
15
|
+
from pfund_plot.renderers.base import BaseRenderer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotebookRenderer(BaseRenderer):
|
|
19
|
+
# REVIEW: if want more control of the callback thread in marimo, add it back
|
|
20
|
+
# def run_periodic_callbacks(self):
|
|
21
|
+
# if self._notebook_type != NotebookType.marimo:
|
|
22
|
+
# super().run_periodic_callbacks()
|
|
23
|
+
# return
|
|
24
|
+
# # Don't use the thread created by periodic_callback.start(),
|
|
25
|
+
# # instead create a marimo thread to stream updates.
|
|
26
|
+
# def stream_updates(_periodic_callback: PeriodicCallback):
|
|
27
|
+
# thread = mo.current_thread()
|
|
28
|
+
# time.sleep(1) # HACK: wait some time to avoid data loss during streaming
|
|
29
|
+
# while not thread.should_exit:
|
|
30
|
+
# _periodic_callback.callback()
|
|
31
|
+
# time.sleep(_periodic_callback.period / 1000)
|
|
32
|
+
|
|
33
|
+
# for periodic_callback in self._periodic_callbacks:
|
|
34
|
+
# stream_thread = mo.Thread(target=stream_updates, args=(periodic_callback,), daemon=True)
|
|
35
|
+
# stream_thread.start()
|
|
36
|
+
|
|
37
|
+
def render(
|
|
38
|
+
self,
|
|
39
|
+
component: Component,
|
|
40
|
+
use_iframe: bool = False,
|
|
41
|
+
iframe_style: str | None = None,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Args:
|
|
45
|
+
use_iframe: if True, use an iframe to display the plot in a notebook.
|
|
46
|
+
It is a workaround when the plot can't be displayed in a notebook.
|
|
47
|
+
iframe_style: the style of the iframe when use_iframe is True.
|
|
48
|
+
"""
|
|
49
|
+
if not self.is_in_notebook_env():
|
|
50
|
+
raise ValueError("Not in a notebook environment")
|
|
51
|
+
|
|
52
|
+
if not use_iframe:
|
|
53
|
+
self.run_periodic_callbacks()
|
|
54
|
+
if self._notebook_type == NotebookType.marimo:
|
|
55
|
+
return mo.as_html(component)
|
|
56
|
+
else:
|
|
57
|
+
return component
|
|
58
|
+
else:
|
|
59
|
+
raise NotImplementedError("Iframe is not supported in notebook mode yet")
|
|
60
|
+
# if iframe_style is None:
|
|
61
|
+
# cprint(
|
|
62
|
+
# "No iframe_style is provided for iframe in notebook",
|
|
63
|
+
# style=TextStyle.BOLD + RichColor.YELLOW
|
|
64
|
+
# )
|
|
65
|
+
# port = self._get_free_port()
|
|
66
|
+
# if self._notebook_type == NotebookType.jupyter:
|
|
67
|
+
# cprint(
|
|
68
|
+
# f"If the plot can't be displayed, try to use 'from IPython.display import IFrame' and 'IFrame(src='http://localhost:{port}', width=..., height=...)'",
|
|
69
|
+
# style=TextStyle.BOLD + RichColor.YELLOW
|
|
70
|
+
# )
|
|
71
|
+
# html_pane: Pane = pn.pane.HTML(
|
|
72
|
+
# f'''
|
|
73
|
+
# <iframe
|
|
74
|
+
# src="http://localhost:{port}"
|
|
75
|
+
# style="{iframe_style}"
|
|
76
|
+
# </iframe>
|
|
77
|
+
# ''',
|
|
78
|
+
# )
|
|
79
|
+
# # let pane HTML inherit the height, width and sizing_mode from the figure, useful in layout_plot()
|
|
80
|
+
# html_pane.param.update(
|
|
81
|
+
# height=component.height,
|
|
82
|
+
# width=component.width,
|
|
83
|
+
# sizing_mode=component.sizing_mode,
|
|
84
|
+
# )
|
|
85
|
+
# thread: StoppableThread = self.serve(component, show=False, threaded=True, port=port)
|
|
86
|
+
# self.run_periodic_callbacks()
|
|
87
|
+
# return thread
|
|
88
|
+
# FIXME: should return html_pane instead of thread?
|
|
89
|
+
# if self._notebook_type == NotebookType.marimo:
|
|
90
|
+
# return mo.as_html(html_pane)
|
|
91
|
+
# else:
|
|
92
|
+
# return html_pane
|