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.
Files changed (28) hide show
  1. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/CHANGELOG.md +14 -3
  2. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/PKG-INFO +56 -16
  3. shiny_plotly-0.2.0/README.md +239 -0
  4. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/examples/core_app.py +3 -3
  5. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/examples/express_app.py +1 -2
  6. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/pyproject.toml +13 -3
  7. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/_deps.py +13 -7
  8. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/_html.py +18 -11
  9. shiny_plotly-0.2.0/src/shiny_plotly/_render.py +114 -0
  10. shiny_plotly-0.2.0/src/shiny_plotly/_serve.py +187 -0
  11. shiny_plotly-0.2.0/src/shiny_plotly/www/shiny-plotly.js +149 -0
  12. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/apps.py +18 -4
  13. shiny_plotly-0.2.0/tests/browser/test_browser.py +292 -0
  14. shiny_plotly-0.2.0/tests/test_compressed_js.py +158 -0
  15. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/test_render_plotly.py +83 -53
  16. shiny_plotly-0.1.0/README.md +0 -201
  17. shiny_plotly-0.1.0/src/shiny_plotly/_render.py +0 -93
  18. shiny_plotly-0.1.0/src/shiny_plotly/www/shiny-plotly.js +0 -47
  19. shiny_plotly-0.1.0/tests/browser/test_browser.py +0 -148
  20. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/.gitignore +0 -0
  21. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/LICENSE +0 -0
  22. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/__init__.py +0 -0
  23. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/src/shiny_plotly/py.typed +0 -0
  24. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/__init__.py +0 -0
  25. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/browser/conftest.py +0 -0
  26. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/newplot.py +0 -0
  27. {shiny_plotly-0.1.0 → shiny_plotly-0.2.0}/tests/test_fig_to_ui.py +0 -0
  28. {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.1.0
4
- Summary: Render plotly figures in Shiny for Python as plain HTML, without the shinywidgets layer.
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/) as plain HTML, without the shinywidgets layer.
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 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`.
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` 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.
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, plotly_js, render_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 after the plot is drawn
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 renders nothing. The function may be sync or async. It may also return `fig.to_dict()` instead of a `Figure`.
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
- Two things change on purpose:
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 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.
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 `Plotly.newPlot` resolves; `{plot_id}` is replaced with the graph div's id.
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 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.
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, 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.
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
+ [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
8
+ [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](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, plotly_js, render_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
- return go.Figure(trace).update_layout(title=f"{input.n()} points")
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 plotly_js, render_plotly
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.1.0"
8
- description = "Render plotly figures in Shiny for Python as plain HTML, without the shinywidgets layer."
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
- 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.
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 rendered figure depends on.
41
+ The small browser helper every output and fragment depends on.
37
42
 
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
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(