shiny-plotly 0.1.0__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.
- shiny_plotly/__init__.py +15 -0
- shiny_plotly/_deps.py +48 -0
- shiny_plotly/_html.py +113 -0
- shiny_plotly/_render.py +93 -0
- shiny_plotly/py.typed +0 -0
- shiny_plotly/www/shiny-plotly.js +47 -0
- shiny_plotly-0.1.0.dist-info/METADATA +229 -0
- shiny_plotly-0.1.0.dist-info/RECORD +10 -0
- shiny_plotly-0.1.0.dist-info/WHEEL +4 -0
- shiny_plotly-0.1.0.dist-info/licenses/LICENSE +21 -0
shiny_plotly/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Render plotly figures in Shiny for Python without the shinywidgets layer."""
|
|
2
|
+
|
|
3
|
+
from ._deps import __version__, plotly_js, shiny_plotly_js
|
|
4
|
+
from ._html import FIGUREWIDGET_MARGINS, fig_to_ui
|
|
5
|
+
from ._render import output_plotly, render_plotly
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"FIGUREWIDGET_MARGINS",
|
|
9
|
+
"__version__",
|
|
10
|
+
"fig_to_ui",
|
|
11
|
+
"output_plotly",
|
|
12
|
+
"plotly_js",
|
|
13
|
+
"render_plotly",
|
|
14
|
+
"shiny_plotly_js",
|
|
15
|
+
)
|
shiny_plotly/_deps.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
import plotly
|
|
6
|
+
from htmltools import HTMLDependency
|
|
7
|
+
|
|
8
|
+
__all__ = ("__version__", "plotly_js", "shiny_plotly_js")
|
|
9
|
+
|
|
10
|
+
__version__ = version("shiny-plotly")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def plotly_js() -> HTMLDependency:
|
|
14
|
+
"""
|
|
15
|
+
The plotly.js bundle, served by Shiny straight from the installed ``plotly`` wheel.
|
|
16
|
+
|
|
17
|
+
Shiny serves HTML dependencies under ``/lib/<name>-<version>/``, so the URL is keyed
|
|
18
|
+
by the installed plotly version and caches correctly across deploys. Nothing is
|
|
19
|
+
copied or written: the dependency points at ``plotly/package_data/plotly.min.js``,
|
|
20
|
+
the exact bundle ``plotly.offline.get_plotlyjs()`` would inline.
|
|
21
|
+
|
|
22
|
+
Add it once to the page UI so the bundle loads with the page. Every figure rendered by
|
|
23
|
+
:func:`~shiny_plotly.render_plotly` or :func:`~shiny_plotly.fig_to_ui` also carries it,
|
|
24
|
+
so an output still works without the page-level call; htmltools de-duplicates.
|
|
25
|
+
"""
|
|
26
|
+
return HTMLDependency(
|
|
27
|
+
name="plotly",
|
|
28
|
+
version=plotly.__version__,
|
|
29
|
+
source={"package": "plotly", "subdir": "package_data"},
|
|
30
|
+
script={"src": "plotly.min.js"},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def shiny_plotly_js() -> HTMLDependency:
|
|
35
|
+
"""
|
|
36
|
+
The small browser helper every rendered figure depends on.
|
|
37
|
+
|
|
38
|
+
It keeps each graph sized to its container (plotly alone only reacts to window
|
|
39
|
+
resizes) and purges a graph once Shiny has replaced the output holding it, so
|
|
40
|
+
re-rendering outputs do not accumulate plotly state. It rides along with every
|
|
41
|
+
fragment; there is no need to add it to the page yourself.
|
|
42
|
+
"""
|
|
43
|
+
return HTMLDependency(
|
|
44
|
+
name="shiny-plotly",
|
|
45
|
+
version=__version__,
|
|
46
|
+
source={"package": "shiny_plotly", "subdir": "www"},
|
|
47
|
+
script={"src": "shiny-plotly.js"},
|
|
48
|
+
)
|
shiny_plotly/_html.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import plotly.io as pio
|
|
9
|
+
from htmltools import HTML, Tag, TagList, css, tags
|
|
10
|
+
from plotly.basedatatypes import BaseFigure
|
|
11
|
+
|
|
12
|
+
from ._deps import plotly_js, shiny_plotly_js
|
|
13
|
+
|
|
14
|
+
__all__ = ("FIGUREWIDGET_MARGINS", "fig_to_ui")
|
|
15
|
+
|
|
16
|
+
# The margins shinywidgets installs on every plotly FigureWidget it renders (its
|
|
17
|
+
# set_layout_defaults: template.layout.margin = l16/t32/r16/b16), so a migrated app can
|
|
18
|
+
# keep its exact look. Plotly's own defaults are l80/t100/r80/b80.
|
|
19
|
+
FIGUREWIDGET_MARGINS: Mapping[str, int] = MappingProxyType({"l": 16, "t": 32, "r": 16, "b": 16})
|
|
20
|
+
|
|
21
|
+
_DEFAULT_CONFIG: Mapping[str, Any] = MappingProxyType({"responsive": True})
|
|
22
|
+
|
|
23
|
+
# Height of a filling container when nothing constrains it, and its flex basis inside a
|
|
24
|
+
# fill layout; the same 400px shinywidgets gives a FigureWidget.
|
|
25
|
+
_FILL_BASIS = "400px"
|
|
26
|
+
|
|
27
|
+
# Runs right after Plotly.newPlot resolves: hands the graph div to the browser helper
|
|
28
|
+
# (shiny-plotly.js), which tracks its size and purges it when Shiny replaces the output.
|
|
29
|
+
_TRACK_SCRIPT = "window.shinyPlotly && shinyPlotly.track(document.getElementById('{plot_id}'));"
|
|
30
|
+
|
|
31
|
+
Figure = BaseFigure | dict[str, Any]
|
|
32
|
+
"""A ``plotly.graph_objects.Figure`` or its JSON dict (``fig.to_dict()``)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fig_to_ui(
|
|
36
|
+
fig: Figure | None,
|
|
37
|
+
div_id: str | None = None,
|
|
38
|
+
*,
|
|
39
|
+
height: str | None = None,
|
|
40
|
+
width: str = "100%",
|
|
41
|
+
figurewidget_margins: bool = False,
|
|
42
|
+
config: Mapping[str, Any] | None = None,
|
|
43
|
+
post_script: str | None = None,
|
|
44
|
+
) -> TagList | None:
|
|
45
|
+
"""
|
|
46
|
+
Turn a plotly figure into a Shiny UI fragment that draws it with ``Plotly.newPlot``.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
fig
|
|
51
|
+
A ``go.Figure`` or its JSON dict. ``None`` renders nothing (returns ``None``).
|
|
52
|
+
div_id
|
|
53
|
+
DOM id of the plotly graph div. A fresh id is generated when omitted.
|
|
54
|
+
height
|
|
55
|
+
CSS height of the plot. ``None`` (the default) fills the parent: inside a fill
|
|
56
|
+
layout (``ui.card(full_screen=True)``, a fillable page, ``output_plotly``) the plot
|
|
57
|
+
grows and shrinks with it from a 400px basis; anywhere else it is 400px tall. A
|
|
58
|
+
value such as ``"300px"`` fixes the height and opts out of filling, exactly like
|
|
59
|
+
``output_widget(height=...)`` does in shinywidgets.
|
|
60
|
+
width
|
|
61
|
+
CSS width of the plot, ``"100%"`` by default.
|
|
62
|
+
figurewidget_margins
|
|
63
|
+
Fill in margin sides the figure left unset with :data:`FIGUREWIDGET_MARGINS`, the
|
|
64
|
+
values shinywidgets applies to a FigureWidget. Sides the figure sets explicitly win.
|
|
65
|
+
The caller's figure object is never mutated.
|
|
66
|
+
config
|
|
67
|
+
Extra ``Plotly.newPlot`` config, merged over ``{"responsive": True}``.
|
|
68
|
+
post_script
|
|
69
|
+
JavaScript run after the plot is drawn; ``{plot_id}`` is replaced with the graph div
|
|
70
|
+
id. The place to bind plotly events back to Shiny inputs.
|
|
71
|
+
"""
|
|
72
|
+
if fig is None:
|
|
73
|
+
return None
|
|
74
|
+
fig_dict = _as_fig_dict(fig)
|
|
75
|
+
if figurewidget_margins:
|
|
76
|
+
_fill_in_margins(fig_dict)
|
|
77
|
+
if div_id is None:
|
|
78
|
+
div_id = "plotly-" + uuid.uuid4().hex
|
|
79
|
+
|
|
80
|
+
fragment = pio.to_html(
|
|
81
|
+
fig_dict,
|
|
82
|
+
validate=False,
|
|
83
|
+
full_html=False,
|
|
84
|
+
include_plotlyjs=False,
|
|
85
|
+
include_mathjax=False,
|
|
86
|
+
div_id=div_id,
|
|
87
|
+
config={**_DEFAULT_CONFIG, **(config or {})},
|
|
88
|
+
post_script=[_TRACK_SCRIPT, post_script] if post_script else [_TRACK_SCRIPT],
|
|
89
|
+
)
|
|
90
|
+
container: Tag = tags.div(
|
|
91
|
+
HTML(fragment),
|
|
92
|
+
class_="shiny-plotly html-fill-item" if height is None else "shiny-plotly",
|
|
93
|
+
style=css(height=height or _FILL_BASIS, width=width),
|
|
94
|
+
)
|
|
95
|
+
return TagList(plotly_js(), shiny_plotly_js(), container)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as_fig_dict(fig: Figure) -> dict[str, Any]:
|
|
99
|
+
# Figure.to_dict() does no validation (the figure was validated when built), and a
|
|
100
|
+
# dict is passed through as the caller's JSON; pio.to_html gets validate=False so it
|
|
101
|
+
# never reconstructs a Figure from it.
|
|
102
|
+
if isinstance(fig, BaseFigure):
|
|
103
|
+
return fig.to_dict()
|
|
104
|
+
if isinstance(fig, dict):
|
|
105
|
+
return {**fig, "layout": dict(fig.get("layout") or {})}
|
|
106
|
+
raise TypeError(
|
|
107
|
+
f"fig_to_ui() expects a plotly go.Figure (or its dict), got {type(fig).__name__}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fill_in_margins(fig_dict: dict[str, Any]) -> None:
|
|
112
|
+
layout = fig_dict.setdefault("layout", {})
|
|
113
|
+
layout["margin"] = {**FIGUREWIDGET_MARGINS, **(layout.get("margin") or {})}
|
shiny_plotly/_render.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from htmltools import Tag, css
|
|
7
|
+
from shiny import ui as _ui
|
|
8
|
+
from shiny.render import ui as _render_ui
|
|
9
|
+
from shiny.render.renderer import Jsonifiable, ValueFn
|
|
10
|
+
from shiny.session import require_active_session
|
|
11
|
+
|
|
12
|
+
from ._html import Figure, fig_to_ui
|
|
13
|
+
|
|
14
|
+
__all__ = ("output_plotly", "render_plotly")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def output_plotly(id: str, *, width: str | None = None, height: str | None = None) -> Tag:
|
|
18
|
+
"""
|
|
19
|
+
Placeholder for a :func:`render_plotly` output. A drop-in for ``output_widget(id)``.
|
|
20
|
+
|
|
21
|
+
It is ``ui.output_ui`` made fill-aware: inside ``ui.card(full_screen=True)`` or a
|
|
22
|
+
fillable page the plot grows and shrinks with its container. Passing ``height`` fixes
|
|
23
|
+
the output's height instead (the plot fills that height), the same rule
|
|
24
|
+
``output_widget`` follows. ``ui.output_ui(id)`` also works when no fill behaviour is
|
|
25
|
+
wanted.
|
|
26
|
+
"""
|
|
27
|
+
return _ui.output_ui(
|
|
28
|
+
id, fill=height is None, fillable=True, style=css(width=width, height=height)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class render_plotly(_render_ui):
|
|
33
|
+
"""
|
|
34
|
+
Render a plotly figure as plain HTML drawn with ``Plotly.newPlot``.
|
|
35
|
+
|
|
36
|
+
A drop-in for ``@render_widget`` when the function returns a ``go.Figure`` (or its
|
|
37
|
+
dict). Use bare (``@render_plotly``) or with options::
|
|
38
|
+
|
|
39
|
+
@render_plotly(height="300px", figurewidget_margins=True)
|
|
40
|
+
def sales():
|
|
41
|
+
return px.bar(df, x="month", y="total")
|
|
42
|
+
|
|
43
|
+
Returning ``None`` renders nothing. Works in Core and Express; the decorated function
|
|
44
|
+
may be sync or async.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
height, width
|
|
49
|
+
CSS size of the plot. ``height=None`` (default) fills the output; a value such as
|
|
50
|
+
``"300px"`` fixes it. See :func:`~shiny_plotly.fig_to_ui`.
|
|
51
|
+
figurewidget_margins
|
|
52
|
+
Fill in unset margin sides with the values shinywidgets applies to a FigureWidget
|
|
53
|
+
(l16/t32/r16/b16), so a migrated app keeps its exact look.
|
|
54
|
+
config
|
|
55
|
+
Extra ``Plotly.newPlot`` config, merged over ``{"responsive": True}``.
|
|
56
|
+
post_script
|
|
57
|
+
JavaScript run after the plot is drawn, with ``{plot_id}`` replaced by the graph
|
|
58
|
+
div's id. The place to forward plotly events to Shiny inputs.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
_fn: ValueFn[Figure | None] | None = None,
|
|
64
|
+
*,
|
|
65
|
+
height: str | None = None,
|
|
66
|
+
width: str = "100%",
|
|
67
|
+
figurewidget_margins: bool = False,
|
|
68
|
+
config: Mapping[str, Any] | None = None,
|
|
69
|
+
post_script: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.height = height
|
|
72
|
+
self.width = width
|
|
73
|
+
self.figurewidget_margins = figurewidget_margins
|
|
74
|
+
self.config = config
|
|
75
|
+
self.post_script = post_script
|
|
76
|
+
# Registers _fn (sets output_id from its name) when used as a bare decorator.
|
|
77
|
+
super().__init__(_fn) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
def auto_output_ui(self) -> Tag:
|
|
80
|
+
return output_plotly(self.output_id)
|
|
81
|
+
|
|
82
|
+
async def transform(self, value: Figure) -> Jsonifiable: # type: ignore[override]
|
|
83
|
+
session = require_active_session(None)
|
|
84
|
+
fragment = fig_to_ui(
|
|
85
|
+
value,
|
|
86
|
+
div_id=f"{session.ns(self.output_id)}-plotly",
|
|
87
|
+
height=self.height,
|
|
88
|
+
width=self.width,
|
|
89
|
+
figurewidget_margins=self.figurewidget_margins,
|
|
90
|
+
config=self.config,
|
|
91
|
+
post_script=self.post_script,
|
|
92
|
+
)
|
|
93
|
+
return await super().transform(fragment)
|
shiny_plotly/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Keeps every shiny-plotly graph sized to its container, and releases a graph once Shiny
|
|
2
|
+
// has replaced the output that held it.
|
|
3
|
+
//
|
|
4
|
+
// Plotly re-measures a responsive graph only on window resize, and it registers one window
|
|
5
|
+
// listener per graph div. So a graph whose card changes size without a window resize (a
|
|
6
|
+
// sibling output rendering, a sidebar toggle) stays at its old size, and a graph Shiny has
|
|
7
|
+
// re-rendered is kept alive by that listener forever. One ResizeObserver covers both: a
|
|
8
|
+
// size change re-lays the graph out, and the notification for a detached element (size 0x0,
|
|
9
|
+
// no longer in the document) purges it, which removes the listener and plotly's state.
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
var observer = null;
|
|
14
|
+
|
|
15
|
+
function differs(actual, laidOut) {
|
|
16
|
+
// Same tolerance plotly's own autosize applies before it redraws.
|
|
17
|
+
return Math.abs(actual - laidOut) > 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function onResize(entries) {
|
|
21
|
+
for (var i = 0; i < entries.length; i++) {
|
|
22
|
+
var gd = entries[i].target;
|
|
23
|
+
if (!gd.isConnected) {
|
|
24
|
+
observer.unobserve(gd);
|
|
25
|
+
if (window.Plotly) window.Plotly.purge(gd);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
var rect = entries[i].contentRect;
|
|
29
|
+
var layout = gd._fullLayout;
|
|
30
|
+
if (!layout || (rect.width === 0 && rect.height === 0)) continue;
|
|
31
|
+
if (differs(rect.width, layout.width) || differs(rect.height, layout.height)) {
|
|
32
|
+
window.Plotly.Plots.resize(gd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
window.shinyPlotly = {
|
|
38
|
+
// Called from the fragment right after Plotly.newPlot resolves, with the graph div.
|
|
39
|
+
track: function (gd) {
|
|
40
|
+
if (!gd) return;
|
|
41
|
+
if (observer === null && typeof ResizeObserver === "function") {
|
|
42
|
+
observer = new ResizeObserver(onResize);
|
|
43
|
+
}
|
|
44
|
+
if (observer !== null) observer.observe(gd);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shiny-plotly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render plotly figures in Shiny for Python as plain HTML, without the shinywidgets layer.
|
|
5
|
+
Project-URL: Homepage, https://github.com/rvben/shiny-plotly
|
|
6
|
+
Project-URL: Repository, https://github.com/rvben/shiny-plotly
|
|
7
|
+
Project-URL: Changelog, https://github.com/rvben/shiny-plotly/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/rvben/shiny-plotly/issues
|
|
9
|
+
Author-email: "Ruben J. Jongejan" <ruben.jongejan@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: dashboard,html,plotly,render,shiny
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: htmltools>=0.5
|
|
25
|
+
Requires-Dist: plotly>=5.0
|
|
26
|
+
Requires-Dist: shiny>=1.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# shiny-plotly
|
|
30
|
+
|
|
31
|
+
Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) as plain HTML, without the shinywidgets layer.
|
|
32
|
+
|
|
33
|
+
*An independent project, not affiliated with or endorsed by Posit or Plotly.*
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/shiny-plotly/)
|
|
36
|
+
[](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
40
|
+
|
|
41
|
+
# UI
|
|
42
|
+
output_plotly("sales")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# server
|
|
46
|
+
@render_plotly
|
|
47
|
+
def sales():
|
|
48
|
+
return go.Figure(go.Bar(x=months, y=totals))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That is the whole API surface for the common case. The figure is serialized with plotly's own `to_html`, sent to the browser as a UI fragment, and drawn with `Plotly.newPlot`. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use `@render_widget`.
|
|
52
|
+
|
|
53
|
+
## Why
|
|
54
|
+
|
|
55
|
+
`shinywidgets` renders a plotly figure by wrapping it in a `FigureWidget` and shipping it through the ipywidgets comm protocol. That machinery earns its keep when the app mutates a figure in place (`fig.data[0].y = ...`) and wants the browser to patch it. Most Shiny apps do not do that; they rebuild the figure inside a reactive function and let Shiny re-render the output. For those apps the widget layer is overhead:
|
|
56
|
+
|
|
57
|
+
- extra dependencies (`ipywidgets`, `anywidget`, `shinywidgets`) and their JavaScript bundles on every page;
|
|
58
|
+
- a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
|
|
59
|
+
- figures held as widget state on the server for the life of the session.
|
|
60
|
+
|
|
61
|
+
`shiny-plotly` renders the figure the way plotly itself does, as HTML plus `Plotly.newPlot`, and uses Shiny's `render.ui` for delivery. The plotly.js bundle is served straight from the installed `plotly` wheel, keyed by its version, so nothing is copied or vendored.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uv add shiny-plotly
|
|
67
|
+
# or
|
|
68
|
+
pip install shiny-plotly
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
|
|
72
|
+
|
|
73
|
+
## Use
|
|
74
|
+
|
|
75
|
+
### Core
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import random
|
|
79
|
+
from itertools import accumulate
|
|
80
|
+
|
|
81
|
+
import plotly.graph_objects as go
|
|
82
|
+
from shiny import App, ui
|
|
83
|
+
|
|
84
|
+
from shiny_plotly import output_plotly, plotly_js, render_plotly
|
|
85
|
+
|
|
86
|
+
app_ui = ui.page_fillable(
|
|
87
|
+
ui.input_slider("n", "Points", 10, 500, 100),
|
|
88
|
+
ui.card(
|
|
89
|
+
ui.card_header("Fills the card; try full screen"),
|
|
90
|
+
output_plotly("walk"),
|
|
91
|
+
full_screen=True,
|
|
92
|
+
),
|
|
93
|
+
plotly_js(), # optional: load plotly.js with the page instead of with the first figure
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def server(input, output, session):
|
|
98
|
+
@render_plotly
|
|
99
|
+
def walk():
|
|
100
|
+
rng = random.Random(input.n())
|
|
101
|
+
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
|
|
102
|
+
return go.Figure(go.Scatter(y=y, mode="lines"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
app = App(app_ui, server)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Anything that is a `plotly.graph_objects.Figure` works, including what `plotly.express` builds (install `plotly[express]` for that).
|
|
109
|
+
|
|
110
|
+
### Express
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import random
|
|
114
|
+
from itertools import accumulate
|
|
115
|
+
|
|
116
|
+
import plotly.graph_objects as go
|
|
117
|
+
from shiny.express import input, ui
|
|
118
|
+
|
|
119
|
+
from shiny_plotly import render_plotly
|
|
120
|
+
|
|
121
|
+
ui.page_opts(fillable=True)
|
|
122
|
+
|
|
123
|
+
with ui.sidebar():
|
|
124
|
+
ui.input_slider("n", "Points", 10, 500, 100)
|
|
125
|
+
|
|
126
|
+
with ui.card(full_screen=True):
|
|
127
|
+
|
|
128
|
+
@render_plotly
|
|
129
|
+
def walk():
|
|
130
|
+
rng = random.Random(input.n())
|
|
131
|
+
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
|
|
132
|
+
return go.Figure(go.Scatter(y=y, mode="lines"))
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The decorator creates its own output placeholder in Express, just like `@render_widget` does.
|
|
136
|
+
|
|
137
|
+
### Options
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
@render_plotly(
|
|
141
|
+
height="300px", # fixed height; default None fills the container
|
|
142
|
+
width="100%",
|
|
143
|
+
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
|
|
144
|
+
config={"displaylogo": False},
|
|
145
|
+
post_script=CLICK_TO_INPUT, # JavaScript run after the plot is drawn
|
|
146
|
+
)
|
|
147
|
+
def sales(): ...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`None` from the render function renders nothing. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`.
|
|
151
|
+
|
|
152
|
+
### Migrating from shinywidgets
|
|
153
|
+
|
|
154
|
+
| shinywidgets | shiny-plotly |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `from shinywidgets import output_widget, render_widget` | `from shiny_plotly import output_plotly, render_plotly` |
|
|
157
|
+
| `output_widget("id")` | `output_plotly("id")` |
|
|
158
|
+
| `output_widget("id", height="300px")` | `output_plotly("id", height="300px")` |
|
|
159
|
+
| `@render_widget` | `@render_plotly` |
|
|
160
|
+
| (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
|
|
161
|
+
|
|
162
|
+
Two things change on purpose:
|
|
163
|
+
|
|
164
|
+
- **Margins.** shinywidgets sets tight margins (`l=16, t=32, r=16, b=16`) on every FigureWidget; plotly's own defaults are `80/100/80/80`. `shiny-plotly` uses plotly's defaults unless you pass `figurewidget_margins=True`, which fills in only the sides your figure leaves unset. Set margins explicitly on the figure if you want something else.
|
|
165
|
+
- **In-place mutation.** A `FigureWidget` you keep on the server and mutate (`fig.data[0].y = ...`, `fig.add_trace(...)` after render) is exactly what shinywidgets is for. `shiny-plotly` has no channel for that; return a new figure from the render function and let Shiny re-render. If your app depends on in-place widget updates, stay on shinywidgets for those outputs. Both packages can coexist in one app.
|
|
166
|
+
|
|
167
|
+
### Sizing
|
|
168
|
+
|
|
169
|
+
The rules mirror `output_widget`:
|
|
170
|
+
|
|
171
|
+
- `height=None` (default): the plot fills its container. Inside `ui.card(full_screen=True)`, a fillable page or a sidebar layout it grows and shrinks with the card, from a 400px basis. Outside a fill layout it is 400px tall.
|
|
172
|
+
- `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
|
|
173
|
+
|
|
174
|
+
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every figure) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once Shiny has replaced the output holding it, which releases the window listener and layout state plotly would otherwise keep for every render.
|
|
175
|
+
|
|
176
|
+
### Events back to Shiny
|
|
177
|
+
|
|
178
|
+
`post_script` runs after `Plotly.newPlot` resolves; `{plot_id}` is replaced with the graph div's id.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
CLICK_TO_INPUT = """
|
|
182
|
+
document.getElementById('{plot_id}').on('plotly_click', function (ev) {
|
|
183
|
+
var p = ev.points[0];
|
|
184
|
+
Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
|
|
185
|
+
});
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@render_plotly(post_script=CLICK_TO_INPUT)
|
|
190
|
+
def scatter(): ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@render.text
|
|
194
|
+
def click_info():
|
|
195
|
+
if not input.clicked.is_set():
|
|
196
|
+
return "Click a point."
|
|
197
|
+
pt = input.clicked()
|
|
198
|
+
return f"x={pt['x']}, y={pt['y']}"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`input.clicked()` raises a silent exception while the input has never been set, so check `is_set()` first when the output should show something before the first click.
|
|
202
|
+
|
|
203
|
+
### Lower level
|
|
204
|
+
|
|
205
|
+
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns the `TagList` a render produces: the plotly.js dependency, the helper dependency and the `<div class="shiny-plotly">` holding the figure. Use it from a plain `@render.ui` that composes several things, or from any htmltools context.
|
|
206
|
+
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every figure carries it, so it is optional; add it to the page UI to load the bundle up front instead of with the first figure.
|
|
207
|
+
- `shiny_plotly_js()` is the helper's dependency. Every figure carries it too.
|
|
208
|
+
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
209
|
+
|
|
210
|
+
## Examples
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
214
|
+
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
make sync # uv sync --all-groups
|
|
221
|
+
make browsers # playwright install chromium, once
|
|
222
|
+
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, purge on re-render, full screen, `post_script` click wiring and on-demand loading of plotly.js. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
shiny_plotly/__init__.py,sha256=FKfM23h6W68_CB6vFaiQtaHlf-yTbSFTp705uaKKaZw,402
|
|
2
|
+
shiny_plotly/_deps.py,sha256=-Pay-nYalROIeX9lyu9HZhCwVfchRrIFP51DQk3UfQ8,1789
|
|
3
|
+
shiny_plotly/_html.py,sha256=vjXxntDCP4J7tz2RHvMwvOxU9Fd9FNW7_4sXMCMA3T8,4483
|
|
4
|
+
shiny_plotly/_render.py,sha256=IlPk4ngFAb3GvccEsDViLTRWR2y-TtbVetcVsFojt2w,3446
|
|
5
|
+
shiny_plotly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
shiny_plotly/www/shiny-plotly.js,sha256=R6HsW8T8qjcBeqL_DR5_yep_kRhxk0_-9Iih1__ZV_I,1794
|
|
7
|
+
shiny_plotly-0.1.0.dist-info/METADATA,sha256=kja8lvqOHWrnDcaoav3HfR6cfHkp3D7OSDlnmUF5Pm4,9839
|
|
8
|
+
shiny_plotly-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
shiny_plotly-0.1.0.dist-info/licenses/LICENSE,sha256=5pr_hilUw8EqNYDfW-9iOtxur_eDNkxklljAsSKi9qo,1074
|
|
10
|
+
shiny_plotly-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruben J. Jongejan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|