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,77 @@
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, cast
5
+
6
+ if TYPE_CHECKING:
7
+ from pfeed.requests.market_feed_stream_request import MarketFeedStreamRequest
8
+ from pfund.datas.resolution import Resolution
9
+
10
+ from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
11
+
12
+ from pfund_plot.enums import PlottingBackend
13
+ from pfund_plot.mixins.streaming_market_feed_mixin import StreamingMarketFeedMixin
14
+ from pfund_plot.plots.plot import BasePlot
15
+ from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
16
+ from pfund_plot.widgets.ticker_widget import TickerSelectWidget
17
+
18
+ __all__ = ["Candlestick"]
19
+
20
+
21
+ class CandlestickStyle:
22
+ from pfund_plot.plots.candlestick.bokeh import style as bokeh_style
23
+ from pfund_plot.plots.candlestick.svelte import style as svelte_style
24
+
25
+ bokeh = bokeh_style
26
+ svelte = svelte_style
27
+
28
+
29
+ class CandlestickControl:
30
+ from pfund_plot.plots.candlestick.bokeh import control as bokeh_control
31
+ from pfund_plot.plots.candlestick.svelte import control as svelte_control
32
+
33
+ bokeh = bokeh_control
34
+ svelte = svelte_control
35
+
36
+
37
+ class Candlestick(StreamingMarketFeedMixin, BasePlot):
38
+ REQUIRED_COLS: ClassVar[list[str]] = ["date", "open", "high", "low", "close"]
39
+ OPTIONAL_COLS: ClassVar[list[str]] = ["volume"]
40
+ SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [
41
+ PlottingBackend.bokeh,
42
+ PlottingBackend.svelte,
43
+ ]
44
+ SUPPORT_STREAMING: ClassVar[bool] = True
45
+ SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
46
+ SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]]] = [
47
+ TickerSelectWidget
48
+ ]
49
+ style = CandlestickStyle
50
+ control = CandlestickControl
51
+
52
+ def _create_component(self):
53
+ # NOTE: somehow data update on anywidget (svelte) in marimo notebook doesn't work using Panel
54
+ # (probably need a refresh of the marimo cell to reflect the changes), so use mo.vstack() as a workaround
55
+ if self._is_using_marimo_svelte_combo():
56
+ import marimo as mo
57
+ from pfund_kit.style import RichColor, TextStyle, cprint
58
+
59
+ # NOTE: self._style (a Panel-layer concern) is NOT applied in this case,
60
+ # since we bypass pn.Column. total_height is the only style with a visible
61
+ # effect here, so warn if it was set and is being silently dropped.
62
+ if self._style and self._style.get("total_height") is not None:
63
+ cprint(
64
+ "total_height is not supported in the marimo + svelte combo and will be ignored.",
65
+ style=TextStyle.BOLD + RichColor.YELLOW,
66
+ )
67
+ self._component = mo.vstack([self._anywidget])
68
+ else:
69
+ super()._create_component()
70
+
71
+ def _start_streaming(self):
72
+ requests = cast("list[MarketFeedStreamRequest]", self._feed._requests)
73
+ assert all(
74
+ cast("Resolution", request.target_resolution).is_bar()
75
+ for request in requests
76
+ ), "candlestick streaming only supports bar data"
77
+ super()._start_streaming()
@@ -0,0 +1,124 @@
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
+ DEFAULT_HEIGHT = 280
17
+ DEFAULT_NUM_DATA = 150
18
+
19
+
20
+ def style(
21
+ title: str = "Candlestick",
22
+ xlabel: str = "date",
23
+ ylabel: str = "price",
24
+ pos_color: str = "green",
25
+ neg_color: str = "red",
26
+ bg_color: str = "", # empty string by default because Panel will automatically use the theme color
27
+ grid: bool = True,
28
+ total_height: int | None = None,
29
+ height: int = DEFAULT_HEIGHT,
30
+ width: int | None = None,
31
+ ):
32
+ """
33
+ Args:
34
+ title: the title of the plot
35
+ xlabel: the label of the x-axis
36
+ ylabel: the label of the y-axis
37
+ pos_color: the color of the upward candle, hex code is supported
38
+ neg_color: the color of the downward candle, hex code is supported
39
+ bg_color: the background color of the plot, hex code is supported
40
+ total_height: the height of the component (including the figure + widgets)
41
+ Default is None, when it is None, Panel will automatically adjust its height
42
+ height: the height of the figure
43
+ width: the width of the plot, since the plot is responsive, this is only used in panel layout
44
+ grid: whether to show the grid
45
+ """
46
+ return locals()
47
+
48
+
49
+ def control(
50
+ num_data: int = DEFAULT_NUM_DATA,
51
+ max_data: int | None = None,
52
+ slider_step: int | None = None,
53
+ linked_axes: bool = True,
54
+ update_interval: int = 5000, # ms
55
+ incremental_update: bool = True,
56
+ widgets: bool = True,
57
+ datetime_precision: Literal["d", "s", "ms"] = "s",
58
+ ):
59
+ """
60
+ Args:
61
+ num_data: (DatetimeRangeWidget) initial number of most recent data points to display.
62
+ max_data: (streaming) maximum number of data points kept in memory.
63
+ If None, data will continue to grow unbounded.
64
+ slider_step: (DatetimeRangeWidget) step size in ms for the datetime range slider.
65
+ If None, derived from data resolution.
66
+ linked_axes: whether to link axes across plots in a layout (plt.layout(...)).
67
+ update_interval: (streaming) interval in ms to update the plot. default is 5000 ms.
68
+ incremental_update: (streaming) whether to update even when the bar is incomplete. default is True.
69
+ widgets: whether to show widgets. default is True.
70
+ For granular control, use remove_widgets() to remove specific widget classes.
71
+ datetime_precision: the precision of datetime formatting on the hover tooltip.
72
+ "d" for days (%Y-%m-%d), "s" for seconds (default, %Y-%m-%d %H:%M:%S), "ms" for milliseconds (%Y-%m-%d %H:%M:%S.%3N).
73
+ """
74
+ return locals()
75
+
76
+
77
+ def plot(
78
+ df: nw.DataFrame[Any],
79
+ style: dict[str, Any],
80
+ control: dict[str, Any],
81
+ x: str | None = None,
82
+ y: str | list[str] | None = None,
83
+ **kwargs: Any,
84
+ ) -> Overlay:
85
+ import hvplot
86
+ from bokeh.models import CrosshairTool
87
+
88
+ from pfund_plot.plots.candlestick import Candlestick
89
+ from pfund_plot.utils.bokeh import create_bundled_hover_tool
90
+
91
+ _ = hvplot.extension(PlottingBackend.bokeh)
92
+
93
+ date_col = Candlestick.REQUIRED_COLS[0]
94
+ ohlc_cols = Candlestick.REQUIRED_COLS[1:]
95
+ # include optional cols (e.g. volume) only when present in the df
96
+ value_cols = ohlc_cols + [
97
+ col for col in Candlestick.OPTIONAL_COLS if col in df.columns
98
+ ]
99
+ return (
100
+ df.to_native()
101
+ .hvplot.ohlc(
102
+ date_col,
103
+ ohlc_cols,
104
+ hover_cols=[date_col, *value_cols],
105
+ tools=[
106
+ create_bundled_hover_tool(
107
+ df, date_col, value_cols, control["datetime_precision"]
108
+ ),
109
+ CrosshairTool(dimensions="height", line_color="gray", line_alpha=0.3),
110
+ ],
111
+ responsive=True,
112
+ grid=style["grid"],
113
+ pos_color=style["pos_color"],
114
+ neg_color=style["neg_color"],
115
+ bgcolor=style["bg_color"],
116
+ **kwargs,
117
+ )
118
+ .opts(
119
+ title=style["title"],
120
+ xlabel=style["xlabel"],
121
+ ylabel=style["ylabel"],
122
+ height=style["height"],
123
+ )
124
+ )
@@ -0,0 +1,161 @@
1
+ # pyright: reportUnusedParameter=false
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ import narwhals as nw
8
+ import traitlets
9
+ from anywidget import AnyWidget
10
+
11
+ __all__ = ["CandlestickWidget", "control", "plot", "style"]
12
+
13
+
14
+ DEFAULT_HEIGHT = 280
15
+ DEFAULT_WIDTH = 670
16
+ DEFAULT_NUM_DATA = 150
17
+
18
+ # Live build output in the repo, written by `pixi run js-watch`
19
+ # (vite build --watch). Present only in a source checkout.
20
+ _DEV_BUNDLE = (
21
+ Path(__file__).parents[3] / "js-tap" / "dist" / "components" / "candlestick.js"
22
+ )
23
+ # Packaged copy shipped inside the wheel (`pixi run js-package` copies dist ->
24
+ # pfund_plot/js_tap/components/ before build; `dashboards/` will sit alongside).
25
+ # parents[2] == the pfund_plot package root.
26
+ _DIST_BUNDLE = Path(__file__).parents[2] / "js_tap" / "components" / "candlestick.js"
27
+
28
+ # No PYTHON_ENV / VITE_PORT, no dev server, no port to detect: `_esm` is always a
29
+ # built file. In a source checkout the repo build output exists, so we load that;
30
+ # anywidget watches the file (with ANYWIDGET_HMR=1) and live-reloads on rebuild.
31
+ # Installed from a wheel, only the packaged copy exists, so we load that.
32
+ _ESM = _DEV_BUNDLE if _DEV_BUNDLE.exists() else _DIST_BUNDLE
33
+
34
+
35
+ class CandlestickWidget(AnyWidget):
36
+ _esm = _ESM
37
+ # data plus the two config groups the frontend renders from: `style` (visual)
38
+ # and `control` (behavior). They mirror the Python-side style()/control() dicts,
39
+ # carrying only the keys the chart actually uses (e.g. total_height is a
40
+ # Panel-layer concern and stays out). All are set in __init__, so no
41
+ # default_value is needed.
42
+ data = traitlets.List().tag(sync=True) # list of dicts with OHLC + time
43
+ style = traitlets.Dict().tag(sync=True)
44
+ control = traitlets.Dict().tag(sync=True)
45
+
46
+ def __init__(
47
+ self,
48
+ df: nw.DataFrame[Any],
49
+ style: dict[str, Any],
50
+ control: dict[str, Any],
51
+ ) -> None:
52
+ super().__init__()
53
+ self.style = {
54
+ "height": style["height"],
55
+ "width": style["width"],
56
+ "title": style["title"],
57
+ "xlabel": style["xlabel"],
58
+ "ylabel": style["ylabel"],
59
+ "pos_color": style["pos_color"],
60
+ "neg_color": style["neg_color"],
61
+ "bg_color": style["bg_color"],
62
+ "grid": style["grid"],
63
+ }
64
+ self.control = {"datetime_precision": control["datetime_precision"]}
65
+ self.update_data(df)
66
+
67
+ @staticmethod
68
+ def _format_data(df: nw.DataFrame[Any]) -> list[dict[str, Any]]:
69
+ """
70
+ Convert dataframe to format supported by lightweight-charts,
71
+ e.g. [ { "open": 10, "high": 10.63, "low": 9.49, "close": 9.55, "time": 1642377600 } ]
72
+
73
+ `time` is sent as a UNIX timestamp in seconds (UTCTimestamp). Stringifying
74
+ the datetime instead (e.g. '2025-01-01 09:00:00') is NOT a format
75
+ lightweight-charts parses for intraday: it collapses every bar within a day
76
+ onto a single point. Epoch seconds keep hourly/minute bars distinct and work
77
+ for daily bars too. `dt.timestamp` yields the UTC epoch (naive datetimes are
78
+ read as UTC wall-clock), which lightweight-charts renders back in UTC, so
79
+ input time == displayed time.
80
+ """
81
+ if "date" in df.columns:
82
+ # narwhals' dt.timestamp only goes down to milliseconds; floor to seconds.
83
+ df = df.with_columns(time=nw.col("date").dt.timestamp("ms") // 1_000).drop(
84
+ "date"
85
+ )
86
+ return df.rows(named=True)
87
+
88
+ def update_data(self, df: nw.DataFrame[Any]):
89
+ """Update the widget's data from a DataFrame"""
90
+ self.data = self._format_data(df)
91
+
92
+ def append_data(self, new_df: nw.DataFrame[Any]):
93
+ """Append new data points for streaming"""
94
+ self.data += self._format_data(new_df)
95
+
96
+
97
+ def style(
98
+ title: str = "Candlestick",
99
+ xlabel: str = "date",
100
+ ylabel: str = "price",
101
+ pos_color: str = "#26a69a",
102
+ neg_color: str = "#ef5350",
103
+ bg_color: str = "white",
104
+ grid: bool = True,
105
+ total_height: int | None = None,
106
+ height: int = DEFAULT_HEIGHT,
107
+ width: int = DEFAULT_WIDTH,
108
+ ):
109
+ """
110
+ Args:
111
+ title: the title shown above the plot. Pass an empty string to hide it.
112
+ (lightweight-charts has no native title, so it's rendered as HTML.)
113
+ xlabel: the x-axis label shown below the plot. Empty string to hide.
114
+ (lightweight-charts has no axis titles, so it's rendered as HTML.)
115
+ ylabel: the y-axis label shown left of the plot. Empty string to hide.
116
+ (lightweight-charts has no axis titles, so it's rendered as HTML.)
117
+ pos_color: the color of the upward candle, hex code is supported
118
+ neg_color: the color of the downward candle, hex code is supported
119
+ bg_color: the background color of the plot, hex code is supported
120
+ grid: whether to show the grid
121
+ total_height: the height of the component (including the figure + widgets)
122
+ height: the height of the figure
123
+ width: the width of the plot
124
+ """
125
+ return locals()
126
+
127
+
128
+ def control(
129
+ num_data: int = DEFAULT_NUM_DATA,
130
+ max_data: int | None = None,
131
+ slider_step: int | None = None,
132
+ update_interval: int = 5000, # ms
133
+ incremental_update: bool = True,
134
+ widgets: bool = True,
135
+ datetime_precision: Literal["d", "s"] = "s",
136
+ ):
137
+ """
138
+ Args:
139
+ num_data: (DatetimeRangeWidget) initial number of most recent data points to display.
140
+ max_data: (streaming) maximum number of data points kept in memory.
141
+ If None, data will continue to grow unbounded.
142
+ slider_step: (DatetimeRangeWidget) step size in ms for the datetime range slider.
143
+ If None, derived from data resolution.
144
+ update_interval: (streaming) interval in ms to update the plot. default is 5000 ms.
145
+ incremental_update: (streaming) whether to update even when the bar is incomplete. default is True.
146
+ widgets: whether to show widgets. default is True.
147
+ For granular control, use remove_widgets() to remove specific widget classes.
148
+ datetime_precision: the precision of datetime shown on the time axis / crosshair.
149
+ "d" for days (date only), "s" for seconds (default, %H:%M:%S). Sub-second
150
+ ("ms") is not supported: the time field is a UTCTimestamp (second-resolution).
151
+ """
152
+ return locals()
153
+
154
+
155
+ def plot(
156
+ df: nw.DataFrame[Any],
157
+ style: dict[str, Any],
158
+ control: dict[str, Any],
159
+ **kwargs: Any,
160
+ ) -> CandlestickWidget:
161
+ return CandlestickWidget(df, style, control)
@@ -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 holoviews.core import Dimensioned
7
+
8
+ from pfund_plot.enums import PlottingBackend
9
+ from pfund_plot.plots.plot import BasePlot
10
+
11
+
12
+ class Holoviews(BasePlot):
13
+ SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.holoviews]]] = [
14
+ PlottingBackend.holoviews
15
+ ]
16
+ REQUIRED_DATA: ClassVar[bool] = False
17
+
18
+ def __init__(
19
+ self, fig: Dimensioned, sizing_mode: str | None = None, **pane_kwargs: Any
20
+ ):
21
+ """
22
+ Args:
23
+ fig: A Holoviews Dimensioned 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.HoloViews,
26
+ e.g. height, width, max_width, margin.
27
+ """
28
+ super().__init__(data=None)
29
+ self._plot: Dimensioned = 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,43 @@
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.enums import PlottingBackend
10
+ from pfund_plot.plots.plot import BasePlot
11
+
12
+ __all__ = ["Label"]
13
+
14
+
15
+ class LabelStyle:
16
+ from pfund_plot.plots.label.bokeh import style as bokeh_style
17
+
18
+ bokeh = bokeh_style
19
+
20
+
21
+ class LabelControl:
22
+ from pfund_plot.plots.label.bokeh import control as bokeh_control
23
+
24
+ bokeh = bokeh_control
25
+
26
+
27
+ class Label(BasePlot):
28
+ SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
29
+ style = LabelStyle
30
+ control = LabelControl
31
+
32
+ def __init__(
33
+ self,
34
+ data: IntoFrame,
35
+ text: str,
36
+ x: str | None = None,
37
+ y: str | list[str] | None = None,
38
+ name: str | None = None,
39
+ **reactive_params: Any,
40
+ ):
41
+ self._text = text
42
+ super().__init__(data=data, x=x, y=y, name=name, **reactive_params)
43
+ self._plot_kwargs["text"] = text
@@ -0,0 +1,89 @@
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
+ DEFAULT_COLOR = "black"
17
+ DEFAULT_FONT_SIZE = "10pt"
18
+ DEFAULT_HEIGHT = 280
19
+
20
+
21
+ def style(
22
+ title: str = "Label",
23
+ xlabel: str = "",
24
+ ylabel: str = "",
25
+ color: str = DEFAULT_COLOR,
26
+ font_size: str = DEFAULT_FONT_SIZE,
27
+ text_align: Literal["left", "center", "right"] = "center",
28
+ total_height: int | None = None,
29
+ height: int = DEFAULT_HEIGHT,
30
+ width: int | None = None,
31
+ ):
32
+ """
33
+ Args:
34
+ title: the title of the plot
35
+ xlabel: the label of the x-axis
36
+ ylabel: the label of the y-axis
37
+ color: text color
38
+ font_size: text font size (e.g. '10pt', '12px')
39
+ text_align: horizontal text alignment ('left', 'center', 'right')
40
+ total_height: the height of the component (including the figure + widgets).
41
+ Default is None, Panel will automatically adjust its height.
42
+ height: the height of the figure
43
+ width: the width of the plot, since the plot is responsive, this is only used in panel layout
44
+ """
45
+ return locals()
46
+
47
+
48
+ def control(
49
+ widgets: bool = True,
50
+ linked_axes: bool = True,
51
+ ):
52
+ """
53
+ Args:
54
+ widgets: whether to show widgets. default is True.
55
+ linked_axes: whether to link axes across plots in a layout (plt.layout(...)).
56
+ """
57
+ return locals()
58
+
59
+
60
+ def plot(
61
+ df: nw.DataFrame[Any],
62
+ style: dict[str, Any],
63
+ control: dict[str, Any],
64
+ x: str | None = None,
65
+ y: str | list[str] | None = None,
66
+ **kwargs: Any,
67
+ ) -> Overlay:
68
+ import hvplot
69
+
70
+ _ = hvplot.extension(PlottingBackend.bokeh)
71
+
72
+ return (
73
+ df.to_native()
74
+ .hvplot.labels(
75
+ x=x,
76
+ y=y,
77
+ responsive=True,
78
+ **kwargs,
79
+ )
80
+ .opts(
81
+ title=style["title"],
82
+ xlabel=style["xlabel"],
83
+ ylabel=style["ylabel"],
84
+ height=style["height"],
85
+ text_color=style["color"],
86
+ text_font_size=style["font_size"],
87
+ text_align=style["text_align"],
88
+ )
89
+ )
@@ -0,0 +1,98 @@
1
+ from pfund_kit.style import RichColor, TextStyle, cprint
2
+
3
+ from pfund_plot.enums import DisplayMode
4
+ from pfund_plot.plots.layout.layout import BaseLayout
5
+ from pfund_plot.plots.lazy import LazyPlot
6
+
7
+
8
+ class LayoutStyle:
9
+ from pfund_plot.plots.layout.panel import style as panel_style
10
+
11
+ panel = panel_style
12
+
13
+
14
+ class LayoutControl:
15
+ from pfund_plot.plots.layout.panel import control as panel_control
16
+
17
+ panel = panel_control
18
+
19
+
20
+ class Layout(BaseLayout):
21
+ _MAX_COLS = 12 # maximum number of columns in a grid when using GridStack
22
+
23
+ style = LayoutStyle
24
+ control = LayoutControl
25
+
26
+ def __init__(self, *plots: LazyPlot): # pyright: ignore[reportInconsistentConstructor]
27
+ super().__init__(*plots)
28
+ default_mode = DisplayMode.browser
29
+ self._set_mode(default_mode)
30
+
31
+ def _render(self):
32
+ if self._mode == DisplayMode.desktop:
33
+ cprint(
34
+ "There is a known issue in resizing when using plt.layout (GridStack) in desktop mode. Please consider switching to browser mode instead.",
35
+ style=TextStyle.BOLD + RichColor.YELLOW,
36
+ )
37
+ return super()._render()
38
+
39
+ def _validate_grid_specs(self):
40
+ # Check grid_spec consistency: either all plots have it or none do
41
+ grid_specs = [plot._grid_spec for plot in self._plots]
42
+ has_grid_spec = [grid_spec is not None for grid_spec in grid_specs]
43
+ all_has_grid_spec = all(has_grid_spec)
44
+ if any(has_grid_spec):
45
+ if not all_has_grid_spec:
46
+ raise ValueError("All plots must have grid_spec defined if any do.")
47
+ else:
48
+ # REVIEW: somehow when column index exceeds 12, GridStack doesn't work, columns must be within 0-12
49
+ if any(
50
+ grid_spec[1].stop is not None and grid_spec[1].stop > self._MAX_COLS
51
+ for grid_spec in grid_specs
52
+ ):
53
+ raise ValueError(
54
+ f"Column index exceeds maximum of {self._MAX_COLS}. Grid uses a {self._MAX_COLS}-column system."
55
+ )
56
+ assert self._control is not None, "control is not set"
57
+ assert self._control["num_cols"] <= self._MAX_COLS, (
58
+ f"'num_cols' must be less than or equal to {self._MAX_COLS}"
59
+ )
60
+
61
+ def _warn_if_widgets_with_drag(self):
62
+ """Warn if child plots have widgets and GridStack drag is enabled.
63
+
64
+ GridStack's drag handler intercepts pointer events, which prevents
65
+ click-based widgets (e.g. Select dropdowns, buttons) from working.
66
+ Drag-based widgets (e.g. sliders) are unaffected.
67
+ """
68
+ if self._control is None or not self._control.get("allow_drag", True):
69
+ return
70
+ for lazyplot in self._plots:
71
+ plot = lazyplot._plot
72
+ has_widgets = (
73
+ plot._widgets or plot._streaming_widgets or plot._reactive_widgets
74
+ )
75
+ if not has_widgets:
76
+ for overlay in plot._overlays:
77
+ has_widgets = (
78
+ overlay._widgets
79
+ or overlay._streaming_widgets
80
+ or overlay._reactive_widgets
81
+ )
82
+ if has_widgets:
83
+ break
84
+ if has_widgets:
85
+ cprint(
86
+ "Widgets detected inside Layout with allow_drag=True. Click-based widgets (e.g. dropdowns, buttons) may not work. "
87
+ + "Use plt.layout(...).control(allow_drag=False) to fix this.",
88
+ style=TextStyle.BOLD + RichColor.YELLOW,
89
+ )
90
+ break
91
+
92
+ def _create_component(self):
93
+ self._validate_grid_specs()
94
+ super()._create_component()
95
+
96
+ def _create(self):
97
+ super()._create()
98
+ self._warn_if_widgets_with_drag()