shiny-plotly 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → shiny_plotly-0.2.0}/CHANGELOG.md +14 -3
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/PKG-INFO +56 -16
- shiny_plotly-0.2.0/README.md +239 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/examples/core_app.py +3 -3
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/examples/express_app.py +1 -2
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/pyproject.toml +13 -3
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/_deps.py +13 -7
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/_html.py +18 -11
- shiny_plotly-0.2.0/src/shiny_plotly/_render.py +114 -0
- shiny_plotly-0.2.0/src/shiny_plotly/_serve.py +187 -0
- shiny_plotly-0.2.0/src/shiny_plotly/www/shiny-plotly.js +149 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/apps.py +18 -4
- shiny_plotly-0.2.0/tests/browser/test_browser.py +292 -0
- shiny_plotly-0.2.0/tests/test_compressed_js.py +158 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/test_render_plotly.py +83 -53
- shiny_plotly-0.1.0/README.md +0 -201
- shiny_plotly-0.1.0/src/shiny_plotly/_render.py +0 -93
- shiny_plotly-0.1.0/src/shiny_plotly/www/shiny-plotly.js +0 -47
- shiny_plotly-0.1.0/tests/browser/test_browser.py +0 -148
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/.gitignore +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/LICENSE +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/__init__.py +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/conftest.py +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/newplot.py +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0](https://github.com/rvben/shiny-plotly/compare/v0.1.0...v0.2.0) - 2026-08-19
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `@render_plotly` is now a Shiny output binding instead of a `render.ui`: the figure travels as plotly JSON and `shiny-plotly.js` draws it with `Plotly.newPlot` the first time and `Plotly.react` on every re-render, into one graph div it keeps. Handlers attached by `post_script` stay attached (it runs once, not on every render), a figure with `layout.uirevision` keeps the user's zoom and pan across re-renders, a render error shows in place and `None` empties the output. Trace data is serialized by plotly's encoder, so numpy arrays, pandas columns and datetimes work as they do in `fig.to_json()`.
|
|
15
|
+
- `output_plotly()` carries the plotly.js and helper dependencies itself, so a page-level `plotly_js()` call is no longer needed for it.
|
|
16
|
+
- **Breaking:** `@render_plotly` requires `output_plotly(id)`; `ui.output_ui(id)` no longer draws it. `fig_to_ui` is unchanged and remains the path for `@render.ui`.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `plotly.min.js` is served pre-compressed (brotli with the optional `brotli` extra, gzip otherwise) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding, through a route added in front of Shiny's static mount when the first session renders a figure. Compression runs once per process in a background thread. `SHINY_PLOTLY_NO_COMPRESS=1` turns the route off.
|
|
21
|
+
- `shiny-plotly[brotli]` extra.
|
|
22
|
+
- Bench: one warm-up visit per server before measuring, HTTP accounting from the browser's Resource Timing (cache hits count as zero bytes), and rows for plotly.js on the wire and requests that left the cache on a repeat visit.
|
|
23
|
+
|
|
10
24
|
## [0.1.0] - 2026-08-19
|
|
11
25
|
|
|
12
26
|
### Added
|
|
@@ -19,6 +33,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
33
|
- `figurewidget_margins=True` fills in unset margin sides with the values shinywidgets applies to a `FigureWidget` (l16/t32/r16/b16), for pixel-identical migrations.
|
|
20
34
|
- `config` (merged over `{"responsive": True}`) and `post_script` (with `{plot_id}` substitution) options for forwarding plotly events to Shiny inputs.
|
|
21
35
|
- Test suite: unit tests, in-process Shiny end-to-end tests over a real websocket, and a headless Chromium suite covering sizing, resize tracking, purge on re-render, full screen and `post_script` wiring.
|
|
22
|
-
|
|
23
|
-
[Unreleased]: https://github.com/rvben/shiny-plotly/compare/v0.1.0...HEAD
|
|
24
|
-
[0.1.0]: https://github.com/rvben/shiny-plotly/releases/tag/v0.1.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Render plotly figures in Shiny for Python
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rvben/shiny-plotly
|
|
6
6
|
Project-URL: Repository, https://github.com/rvben/shiny-plotly
|
|
7
7
|
Project-URL: Changelog, https://github.com/rvben/shiny-plotly/blob/main/CHANGELOG.md
|
|
@@ -24,11 +24,13 @@ Requires-Python: >=3.10
|
|
|
24
24
|
Requires-Dist: htmltools>=0.5
|
|
25
25
|
Requires-Dist: plotly>=5.0
|
|
26
26
|
Requires-Dist: shiny>=1.0
|
|
27
|
+
Provides-Extra: brotli
|
|
28
|
+
Requires-Dist: brotli>=1.1; extra == 'brotli'
|
|
27
29
|
Description-Content-Type: text/markdown
|
|
28
30
|
|
|
29
31
|
# shiny-plotly
|
|
30
32
|
|
|
31
|
-
Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/)
|
|
33
|
+
Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) with plain plotly.js, without the shinywidgets layer.
|
|
32
34
|
|
|
33
35
|
*An independent project, not affiliated with or endorsed by Posit or Plotly.*
|
|
34
36
|
|
|
@@ -48,7 +50,7 @@ def sales():
|
|
|
48
50
|
return go.Figure(go.Bar(x=months, y=totals))
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
That is the whole API surface for the common case. The figure
|
|
53
|
+
That is the whole API surface for the common case. The figure travels as plotly's own JSON over Shiny's websocket; a small output binding draws it with `Plotly.newPlot` the first time and `Plotly.react` on every re-render, into one graph div it keeps. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use `@render_widget`; zoom and pan survive it when the figure sets `layout.uirevision`.
|
|
52
54
|
|
|
53
55
|
## Why
|
|
54
56
|
|
|
@@ -58,7 +60,20 @@ That is the whole API surface for the common case. The figure is serialized with
|
|
|
58
60
|
- a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
|
|
59
61
|
- figures held as widget state on the server for the life of the session.
|
|
60
62
|
|
|
61
|
-
`shiny-plotly`
|
|
63
|
+
`shiny-plotly` sends the figure as plotly JSON and draws it with plotly.js directly, through a Shiny output binding. The plotly.js bundle is served straight from the installed `plotly` wheel, keyed by its version, pre-compressed and with an immutable cache lifetime, so nothing is copied or vendored and a browser fetches it once.
|
|
64
|
+
|
|
65
|
+
Measured on the same app (a slider and one fillable card with a line chart; `bench/`), shiny 1.7.0, plotly 6.9.0, shinywidgets 0.8.1, shiny-plotly 0.2.0, headless Chromium, 2026-08-19:
|
|
66
|
+
|
|
67
|
+
| | shinywidgets | shiny-plotly |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| Packages added on top of `shiny` + `plotly` | 24 (38 MB) | 1 (38 kB) |
|
|
70
|
+
| First visit, bytes to the first figure | 10.7 MB (5.3 MB HTTP + 5.4 MB websocket) | 2.6 MB (2.6 MB HTTP + 9 kB websocket) |
|
|
71
|
+
| of which plotly.js over HTTP | 0 (in the websocket) | 1.2 MB (brotli; 1.5 MB gzip) |
|
|
72
|
+
| Repeat visit (warm browser cache) | 5.4 MB, nearly all websocket | 13 kB |
|
|
73
|
+
| Websocket bytes per re-render | 5.4 MB | 10 kB |
|
|
74
|
+
| Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
|
|
75
|
+
|
|
76
|
+
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (in-place `FigureWidget` updates, any ipywidget), which the table does not measure. `make bench` reproduces it; `bench/results.json` holds the raw numbers.
|
|
62
77
|
|
|
63
78
|
## Install
|
|
64
79
|
|
|
@@ -81,7 +96,7 @@ from itertools import accumulate
|
|
|
81
96
|
import plotly.graph_objects as go
|
|
82
97
|
from shiny import App, ui
|
|
83
98
|
|
|
84
|
-
from shiny_plotly import output_plotly,
|
|
99
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
85
100
|
|
|
86
101
|
app_ui = ui.page_fillable(
|
|
87
102
|
ui.input_slider("n", "Points", 10, 500, 100),
|
|
@@ -90,7 +105,6 @@ app_ui = ui.page_fillable(
|
|
|
90
105
|
output_plotly("walk"),
|
|
91
106
|
full_screen=True,
|
|
92
107
|
),
|
|
93
|
-
plotly_js(), # optional: load plotly.js with the page instead of with the first figure
|
|
94
108
|
)
|
|
95
109
|
|
|
96
110
|
|
|
@@ -142,12 +156,24 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
142
156
|
width="100%",
|
|
143
157
|
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
|
|
144
158
|
config={"displaylogo": False},
|
|
145
|
-
post_script=CLICK_TO_INPUT, # JavaScript run
|
|
159
|
+
post_script=CLICK_TO_INPUT, # JavaScript run once, when the graph is first drawn
|
|
146
160
|
)
|
|
147
161
|
def sales(): ...
|
|
148
162
|
```
|
|
149
163
|
|
|
150
|
-
`None` from the render function
|
|
164
|
+
`None` from the render function empties the output. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`. Anything plotly's own encoder serializes is fine as trace data: numpy arrays, pandas columns, datetimes.
|
|
165
|
+
|
|
166
|
+
### Re-renders, zoom and pan
|
|
167
|
+
|
|
168
|
+
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the handlers `post_script` attached and plotly's per-graph state all survive a re-render.
|
|
169
|
+
|
|
170
|
+
Whether the user's zoom and pan survive is plotly's `uirevision` rule, the same one shinywidgets users rely on for in-place updates: set `layout.uirevision` to any value and keep it the same across renders to preserve the view, change it to reset the view, leave it unset to reset on every render.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
@render_plotly
|
|
174
|
+
def prices():
|
|
175
|
+
return px.line(frame(), x="date", y="close").update_layout(uirevision="prices")
|
|
176
|
+
```
|
|
151
177
|
|
|
152
178
|
### Migrating from shinywidgets
|
|
153
179
|
|
|
@@ -159,10 +185,11 @@ def sales(): ...
|
|
|
159
185
|
| `@render_widget` | `@render_plotly` |
|
|
160
186
|
| (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
|
|
161
187
|
|
|
162
|
-
|
|
188
|
+
Three things change on purpose:
|
|
163
189
|
|
|
164
190
|
- **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
191
|
- **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.
|
|
192
|
+
- **Zoom across re-renders.** A mutated `FigureWidget` keeps the user's zoom because nothing replaces the figure. Here a re-render is a new figure, so plotly's `uirevision` decides: set `layout.uirevision` (see above) to keep the view.
|
|
166
193
|
|
|
167
194
|
### Sizing
|
|
168
195
|
|
|
@@ -171,11 +198,11 @@ The rules mirror `output_widget`:
|
|
|
171
198
|
- `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
199
|
- `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
|
|
173
200
|
|
|
174
|
-
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every
|
|
201
|
+
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) 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 it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
175
202
|
|
|
176
203
|
### Events back to Shiny
|
|
177
204
|
|
|
178
|
-
`post_script` runs after
|
|
205
|
+
`post_script` runs once, after the first figure is drawn; `{plot_id}` is replaced with the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so the handlers stay attached and are never stacked.
|
|
179
206
|
|
|
180
207
|
```python
|
|
181
208
|
CLICK_TO_INPUT = """
|
|
@@ -202,11 +229,23 @@ def click_info():
|
|
|
202
229
|
|
|
203
230
|
### Lower level
|
|
204
231
|
|
|
205
|
-
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns
|
|
206
|
-
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every
|
|
207
|
-
- `shiny_plotly_js()` is the helper's dependency. Every
|
|
232
|
+
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns a `TagList` holding the plotly.js dependency, the helper dependency and a `<div class="shiny-plotly">` that draws the figure with `Plotly.newPlot` (plotly's own `to_html` fragment). Use it from a plain `@render.ui` that composes a figure with other UI, or from any htmltools context. Each render draws a fresh graph; an output that is only a figure is better served by `render_plotly`.
|
|
233
|
+
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
234
|
+
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
208
235
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
209
236
|
|
|
237
|
+
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
238
|
+
|
|
239
|
+
### plotly.js on the wire
|
|
240
|
+
|
|
241
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so once the first session of a process has rendered a figure, `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment.
|
|
248
|
+
|
|
210
249
|
## Examples
|
|
211
250
|
|
|
212
251
|
```sh
|
|
@@ -220,9 +259,10 @@ uv run --with shiny-plotly shiny run examples/express_app.py
|
|
|
220
259
|
make sync # uv sync --all-groups
|
|
221
260
|
make browsers # playwright install chromium, once
|
|
222
261
|
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
|
|
262
|
+
make bench # the shinywidgets comparison above, on this machine
|
|
223
263
|
```
|
|
224
264
|
|
|
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,
|
|
265
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `post_script` click wiring (once, not stacked), error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `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
266
|
|
|
227
267
|
## License
|
|
228
268
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# shiny-plotly
|
|
2
|
+
|
|
3
|
+
Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) with plain plotly.js, without the shinywidgets layer.
|
|
4
|
+
|
|
5
|
+
*An independent project, not affiliated with or endorsed by Posit or Plotly.*
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/shiny-plotly/)
|
|
8
|
+
[](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
12
|
+
|
|
13
|
+
# UI
|
|
14
|
+
output_plotly("sales")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# server
|
|
18
|
+
@render_plotly
|
|
19
|
+
def sales():
|
|
20
|
+
return go.Figure(go.Bar(x=months, y=totals))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That is the whole API surface for the common case. The figure travels as plotly's own JSON over Shiny's websocket; a small output binding draws it with `Plotly.newPlot` the first time and `Plotly.react` on every re-render, into one graph div it keeps. No ipywidgets, no kernel comm, no anywidget. Every render replaces the figure, which is how most dashboards already use `@render_widget`; zoom and pan survive it when the figure sets `layout.uirevision`.
|
|
24
|
+
|
|
25
|
+
## Why
|
|
26
|
+
|
|
27
|
+
`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:
|
|
28
|
+
|
|
29
|
+
- extra dependencies (`ipywidgets`, `anywidget`, `shinywidgets`) and their JavaScript bundles on every page;
|
|
30
|
+
- a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
|
|
31
|
+
- figures held as widget state on the server for the life of the session.
|
|
32
|
+
|
|
33
|
+
`shiny-plotly` sends the figure as plotly JSON and draws it with plotly.js directly, through a Shiny output binding. The plotly.js bundle is served straight from the installed `plotly` wheel, keyed by its version, pre-compressed and with an immutable cache lifetime, so nothing is copied or vendored and a browser fetches it once.
|
|
34
|
+
|
|
35
|
+
Measured on the same app (a slider and one fillable card with a line chart; `bench/`), shiny 1.7.0, plotly 6.9.0, shinywidgets 0.8.1, shiny-plotly 0.2.0, headless Chromium, 2026-08-19:
|
|
36
|
+
|
|
37
|
+
| | shinywidgets | shiny-plotly |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| Packages added on top of `shiny` + `plotly` | 24 (38 MB) | 1 (38 kB) |
|
|
40
|
+
| First visit, bytes to the first figure | 10.7 MB (5.3 MB HTTP + 5.4 MB websocket) | 2.6 MB (2.6 MB HTTP + 9 kB websocket) |
|
|
41
|
+
| of which plotly.js over HTTP | 0 (in the websocket) | 1.2 MB (brotli; 1.5 MB gzip) |
|
|
42
|
+
| Repeat visit (warm browser cache) | 5.4 MB, nearly all websocket | 13 kB |
|
|
43
|
+
| Websocket bytes per re-render | 5.4 MB | 10 kB |
|
|
44
|
+
| Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
|
|
45
|
+
|
|
46
|
+
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (in-place `FigureWidget` updates, any ipywidget), which the table does not measure. `make bench` reproduces it; `bench/results.json` holds the raw numbers.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uv add shiny-plotly
|
|
52
|
+
# or
|
|
53
|
+
pip install shiny-plotly
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
### Core
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import random
|
|
64
|
+
from itertools import accumulate
|
|
65
|
+
|
|
66
|
+
import plotly.graph_objects as go
|
|
67
|
+
from shiny import App, ui
|
|
68
|
+
|
|
69
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
70
|
+
|
|
71
|
+
app_ui = ui.page_fillable(
|
|
72
|
+
ui.input_slider("n", "Points", 10, 500, 100),
|
|
73
|
+
ui.card(
|
|
74
|
+
ui.card_header("Fills the card; try full screen"),
|
|
75
|
+
output_plotly("walk"),
|
|
76
|
+
full_screen=True,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def server(input, output, session):
|
|
82
|
+
@render_plotly
|
|
83
|
+
def walk():
|
|
84
|
+
rng = random.Random(input.n())
|
|
85
|
+
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
|
|
86
|
+
return go.Figure(go.Scatter(y=y, mode="lines"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
app = App(app_ui, server)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Anything that is a `plotly.graph_objects.Figure` works, including what `plotly.express` builds (install `plotly[express]` for that).
|
|
93
|
+
|
|
94
|
+
### Express
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import random
|
|
98
|
+
from itertools import accumulate
|
|
99
|
+
|
|
100
|
+
import plotly.graph_objects as go
|
|
101
|
+
from shiny.express import input, ui
|
|
102
|
+
|
|
103
|
+
from shiny_plotly import render_plotly
|
|
104
|
+
|
|
105
|
+
ui.page_opts(fillable=True)
|
|
106
|
+
|
|
107
|
+
with ui.sidebar():
|
|
108
|
+
ui.input_slider("n", "Points", 10, 500, 100)
|
|
109
|
+
|
|
110
|
+
with ui.card(full_screen=True):
|
|
111
|
+
|
|
112
|
+
@render_plotly
|
|
113
|
+
def walk():
|
|
114
|
+
rng = random.Random(input.n())
|
|
115
|
+
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
|
|
116
|
+
return go.Figure(go.Scatter(y=y, mode="lines"))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The decorator creates its own output placeholder in Express, just like `@render_widget` does.
|
|
120
|
+
|
|
121
|
+
### Options
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@render_plotly(
|
|
125
|
+
height="300px", # fixed height; default None fills the container
|
|
126
|
+
width="100%",
|
|
127
|
+
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
|
|
128
|
+
config={"displaylogo": False},
|
|
129
|
+
post_script=CLICK_TO_INPUT, # JavaScript run once, when the graph is first drawn
|
|
130
|
+
)
|
|
131
|
+
def sales(): ...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`None` from the render function empties the output. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`. Anything plotly's own encoder serializes is fine as trace data: numpy arrays, pandas columns, datetimes.
|
|
135
|
+
|
|
136
|
+
### Re-renders, zoom and pan
|
|
137
|
+
|
|
138
|
+
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the handlers `post_script` attached and plotly's per-graph state all survive a re-render.
|
|
139
|
+
|
|
140
|
+
Whether the user's zoom and pan survive is plotly's `uirevision` rule, the same one shinywidgets users rely on for in-place updates: set `layout.uirevision` to any value and keep it the same across renders to preserve the view, change it to reset the view, leave it unset to reset on every render.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
@render_plotly
|
|
144
|
+
def prices():
|
|
145
|
+
return px.line(frame(), x="date", y="close").update_layout(uirevision="prices")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Migrating from shinywidgets
|
|
149
|
+
|
|
150
|
+
| shinywidgets | shiny-plotly |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `from shinywidgets import output_widget, render_widget` | `from shiny_plotly import output_plotly, render_plotly` |
|
|
153
|
+
| `output_widget("id")` | `output_plotly("id")` |
|
|
154
|
+
| `output_widget("id", height="300px")` | `output_plotly("id", height="300px")` |
|
|
155
|
+
| `@render_widget` | `@render_plotly` |
|
|
156
|
+
| (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
|
|
157
|
+
|
|
158
|
+
Three things change on purpose:
|
|
159
|
+
|
|
160
|
+
- **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.
|
|
161
|
+
- **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.
|
|
162
|
+
- **Zoom across re-renders.** A mutated `FigureWidget` keeps the user's zoom because nothing replaces the figure. Here a re-render is a new figure, so plotly's `uirevision` decides: set `layout.uirevision` (see above) to keep the view.
|
|
163
|
+
|
|
164
|
+
### Sizing
|
|
165
|
+
|
|
166
|
+
The rules mirror `output_widget`:
|
|
167
|
+
|
|
168
|
+
- `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.
|
|
169
|
+
- `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
|
|
170
|
+
|
|
171
|
+
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) 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 it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
172
|
+
|
|
173
|
+
### Events back to Shiny
|
|
174
|
+
|
|
175
|
+
`post_script` runs once, after the first figure is drawn; `{plot_id}` is replaced with the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so the handlers stay attached and are never stacked.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
CLICK_TO_INPUT = """
|
|
179
|
+
document.getElementById('{plot_id}').on('plotly_click', function (ev) {
|
|
180
|
+
var p = ev.points[0];
|
|
181
|
+
Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
|
|
182
|
+
});
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@render_plotly(post_script=CLICK_TO_INPUT)
|
|
187
|
+
def scatter(): ...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@render.text
|
|
191
|
+
def click_info():
|
|
192
|
+
if not input.clicked.is_set():
|
|
193
|
+
return "Click a point."
|
|
194
|
+
pt = input.clicked()
|
|
195
|
+
return f"x={pt['x']}, y={pt['y']}"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`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.
|
|
199
|
+
|
|
200
|
+
### Lower level
|
|
201
|
+
|
|
202
|
+
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns a `TagList` holding the plotly.js dependency, the helper dependency and a `<div class="shiny-plotly">` that draws the figure with `Plotly.newPlot` (plotly's own `to_html` fragment). Use it from a plain `@render.ui` that composes a figure with other UI, or from any htmltools context. Each render draws a fresh graph; an output that is only a figure is better served by `render_plotly`.
|
|
203
|
+
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
204
|
+
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
205
|
+
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
206
|
+
|
|
207
|
+
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
208
|
+
|
|
209
|
+
### plotly.js on the wire
|
|
210
|
+
|
|
211
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so once the first session of a process has rendered a figure, `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment.
|
|
218
|
+
|
|
219
|
+
## Examples
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
223
|
+
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
make sync # uv sync --all-groups
|
|
230
|
+
make browsers # playwright install chromium, once
|
|
231
|
+
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
|
|
232
|
+
make bench # the shinywidgets comparison above, on this machine
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `post_script` click wiring (once, not stacked), error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `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.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -9,7 +9,7 @@ from itertools import accumulate
|
|
|
9
9
|
import plotly.graph_objects as go
|
|
10
10
|
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
|
|
11
11
|
|
|
12
|
-
from shiny_plotly import output_plotly,
|
|
12
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
13
13
|
|
|
14
14
|
# Forwards plotly click events to a Shiny input. {plot_id} is the graph div's id.
|
|
15
15
|
CLICK_TO_INPUT = """
|
|
@@ -41,7 +41,6 @@ app_ui = ui.page_sidebar(
|
|
|
41
41
|
ui.card_header("Fixed height, click a point"),
|
|
42
42
|
output_plotly("fixed_plot"),
|
|
43
43
|
),
|
|
44
|
-
plotly_js(),
|
|
45
44
|
title="shiny-plotly",
|
|
46
45
|
fillable=True,
|
|
47
46
|
)
|
|
@@ -61,7 +60,8 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
61
60
|
trace = go.Scatter(x=x, y=y, mode="lines")
|
|
62
61
|
else:
|
|
63
62
|
trace = go.Bar(x=x, y=y)
|
|
64
|
-
|
|
63
|
+
# uirevision keeps the zoom the user dragged across re-renders of the same kind.
|
|
64
|
+
return go.Figure(trace).update_layout(title=f"{input.n()} points", uirevision=input.kind())
|
|
65
65
|
|
|
66
66
|
@render_plotly(figurewidget_margins=True)
|
|
67
67
|
def compact_plot():
|
|
@@ -9,10 +9,9 @@ from itertools import accumulate
|
|
|
9
9
|
import plotly.graph_objects as go
|
|
10
10
|
from shiny.express import input, ui
|
|
11
11
|
|
|
12
|
-
from shiny_plotly import
|
|
12
|
+
from shiny_plotly import render_plotly
|
|
13
13
|
|
|
14
14
|
ui.page_opts(title="shiny-plotly (Express)", fillable=True)
|
|
15
|
-
plotly_js()
|
|
16
15
|
|
|
17
16
|
with ui.sidebar():
|
|
18
17
|
ui.input_slider("n", "Points", min=10, max=500, value=100)
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Render plotly figures in Shiny for Python
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
@@ -31,6 +31,10 @@ dependencies = [
|
|
|
31
31
|
"htmltools>=0.5",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
# plotly.min.js is served brotli-compressed (about 15% smaller than gzip) when present.
|
|
36
|
+
brotli = ["brotli>=1.1"]
|
|
37
|
+
|
|
34
38
|
[project.urls]
|
|
35
39
|
Homepage = "https://github.com/rvben/shiny-plotly"
|
|
36
40
|
Repository = "https://github.com/rvben/shiny-plotly"
|
|
@@ -38,6 +42,10 @@ Changelog = "https://github.com/rvben/shiny-plotly/blob/main/CHANGELOG.md"
|
|
|
38
42
|
Issues = "https://github.com/rvben/shiny-plotly/issues"
|
|
39
43
|
|
|
40
44
|
[dependency-groups]
|
|
45
|
+
bench = [
|
|
46
|
+
"anywidget>=0.11.0",
|
|
47
|
+
"shinywidgets>=0.8.1",
|
|
48
|
+
]
|
|
41
49
|
dev = [
|
|
42
50
|
"pytest>=8",
|
|
43
51
|
"pytest-asyncio>=0.24",
|
|
@@ -45,6 +53,8 @@ dev = [
|
|
|
45
53
|
"pyright>=1.1.380",
|
|
46
54
|
"httpx2>=2.12.0",
|
|
47
55
|
"pytest-playwright>=0.9.0",
|
|
56
|
+
"numpy>=1.26",
|
|
57
|
+
"brotli>=1.1",
|
|
48
58
|
]
|
|
49
59
|
|
|
50
60
|
[tool.hatch.build.targets.wheel]
|
|
@@ -68,7 +78,7 @@ target-version = "py310"
|
|
|
68
78
|
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
69
79
|
|
|
70
80
|
[tool.pyright]
|
|
71
|
-
include = ["src", "tests", "examples"]
|
|
81
|
+
include = ["src", "tests", "examples", "bench"]
|
|
72
82
|
pythonVersion = "3.10"
|
|
73
83
|
typeCheckingMode = "standard"
|
|
74
84
|
|
|
@@ -19,9 +19,14 @@ def plotly_js() -> HTMLDependency:
|
|
|
19
19
|
copied or written: the dependency points at ``plotly/package_data/plotly.min.js``,
|
|
20
20
|
the exact bundle ``plotly.offline.get_plotlyjs()`` would inline.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
Every :func:`~shiny_plotly.output_plotly` and every :func:`~shiny_plotly.fig_to_ui`
|
|
23
|
+
fragment carries it, so nothing needs to be added to the page for it; htmltools
|
|
24
|
+
de-duplicates. Add it to the page UI yourself only when the first figure is inserted
|
|
25
|
+
later (``ui.insert_ui``, a ``@render.ui`` that starts empty) and the bundle should load
|
|
26
|
+
with the page instead.
|
|
27
|
+
|
|
28
|
+
Once a session has rendered a figure, the bundle is served pre-compressed with an
|
|
29
|
+
immutable cache lifetime; see :mod:`shiny_plotly._serve`.
|
|
25
30
|
"""
|
|
26
31
|
return HTMLDependency(
|
|
27
32
|
name="plotly",
|
|
@@ -33,11 +38,12 @@ def plotly_js() -> HTMLDependency:
|
|
|
33
38
|
|
|
34
39
|
def shiny_plotly_js() -> HTMLDependency:
|
|
35
40
|
"""
|
|
36
|
-
The small browser helper every
|
|
41
|
+
The small browser helper every output and fragment depends on.
|
|
37
42
|
|
|
38
|
-
It
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
It holds the output binding for :func:`~shiny_plotly.output_plotly` (``Plotly.newPlot``
|
|
44
|
+
once, ``Plotly.react`` on every re-render), keeps each graph sized to its container
|
|
45
|
+
(plotly alone only reacts to window resizes) and purges a graph once it leaves the
|
|
46
|
+
document, so nothing accumulates plotly state. It rides along with every output and
|
|
41
47
|
fragment; there is no need to add it to the page yourself.
|
|
42
48
|
"""
|
|
43
49
|
return HTMLDependency(
|