shiny-plotly 0.2.0__tar.gz → 0.3.1__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 (42) hide show
  1. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/.gitignore +1 -0
  2. shiny_plotly-0.3.1/CHANGELOG.md +61 -0
  3. shiny_plotly-0.3.1/PKG-INFO +422 -0
  4. shiny_plotly-0.3.1/README.md +391 -0
  5. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/examples/core_app.py +36 -16
  6. shiny_plotly-0.3.1/examples/dark_app.py +53 -0
  7. shiny_plotly-0.3.1/examples/events_app.py +64 -0
  8. shiny_plotly-0.3.1/examples/shinylive/app.py +33 -0
  9. shiny_plotly-0.3.1/examples/shinylive/requirements.txt +3 -0
  10. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/pyproject.toml +13 -6
  11. shiny_plotly-0.3.1/src/shiny_plotly/__init__.py +34 -0
  12. shiny_plotly-0.3.1/src/shiny_plotly/_render.py +273 -0
  13. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_serve.py +16 -6
  14. shiny_plotly-0.3.1/src/shiny_plotly/_update.py +220 -0
  15. shiny_plotly-0.3.1/src/shiny_plotly/www/shiny-plotly.js +350 -0
  16. shiny_plotly-0.3.1/tests/browser/apps.py +353 -0
  17. shiny_plotly-0.3.1/tests/browser/conftest.py +55 -0
  18. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/browser/test_browser.py +1 -10
  19. shiny_plotly-0.3.1/tests/browser/test_dark_mode.py +99 -0
  20. shiny_plotly-0.3.1/tests/browser/test_events.py +160 -0
  21. shiny_plotly-0.3.1/tests/browser/test_update.py +202 -0
  22. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/test_compressed_js.py +47 -1
  23. shiny_plotly-0.3.1/tests/test_examples.py +40 -0
  24. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/test_render_plotly.py +201 -6
  25. shiny_plotly-0.3.1/tests/test_update.py +203 -0
  26. shiny_plotly-0.2.0/CHANGELOG.md +0 -35
  27. shiny_plotly-0.2.0/PKG-INFO +0 -269
  28. shiny_plotly-0.2.0/README.md +0 -239
  29. shiny_plotly-0.2.0/src/shiny_plotly/__init__.py +0 -15
  30. shiny_plotly-0.2.0/src/shiny_plotly/_render.py +0 -114
  31. shiny_plotly-0.2.0/src/shiny_plotly/www/shiny-plotly.js +0 -149
  32. shiny_plotly-0.2.0/tests/browser/apps.py +0 -80
  33. shiny_plotly-0.2.0/tests/browser/conftest.py +0 -29
  34. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/LICENSE +0 -0
  35. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/examples/express_app.py +0 -0
  36. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_deps.py +0 -0
  37. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_html.py +0 -0
  38. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/src/shiny_plotly/py.typed +0 -0
  39. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/browser/__init__.py +0 -0
  40. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/newplot.py +0 -0
  41. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/test_fig_to_ui.py +0 -0
  42. {shiny_plotly-0.2.0 → shiny_plotly-0.3.1}/tests/test_plotly_js.py +0 -0
@@ -1,5 +1,6 @@
1
1
  .venv/
2
2
  .wheel-venv/
3
+ .floor-venv/
3
4
  dist/
4
5
  __pycache__/
5
6
  *.pyc
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.1](https://github.com/rvben/shiny-plotly/compare/v0.3.0...v0.3.1) - 2026-08-20
11
+
12
+ ### Added
13
+
14
+ - `@render_plotly(theme="auto")`: the figure follows the page's color mode in the browser, with no server round-trip. `"auto"` pairs plotly's `"plotly"` and `"plotly_dark"` templates; a `(light, dark)` tuple picks others, each a registered name, a `Template` object or a template dict. Both templates travel with the figure with transparent backgrounds so the page shows through; the browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when `data-bs-theme` (what `ui.input_dark_mode()` sets) or the OS `prefers-color-scheme` flips. Unknown template names raise at decoration time.
15
+ - The in-place update family is complete: `add_traces`, `delete_traces`, `prepend_traces` and `update` (a restyle and a relayout in one redraw) join `extend_traces`, `restyle` and `relayout`. Same contract as the first three: values through plotly's encoder, ids namespaced inside a module, updates held and applied in order while the output has no figure drawn.
16
+ - Examples for dark mode (`examples/dark_app.py`), dense-trace selections (`examples/events_app.py`) and a ready-to-export shinylive app (`examples/shinylive/`); a test keeps every example building.
17
+
18
+ ### Fixed
19
+
20
+ - Shinylive: under pyodide the compressed plotly.js route is skipped instead of crashing the session. Pyodide cannot start threads, so the compression thread raised `RuntimeError: can't start new thread` from the first `@render_plotly` of every session and no figure ever rendered; there is also nothing to serve there, as the browser loads assets from the shinylive bundle. Verified against a real `shinylive export`: 0.3.0 crashes, this version renders.
21
+
22
+ ## [0.3.0](https://github.com/rvben/shiny-plotly/compare/v0.2.0...v0.3.0) - 2026-08-20
23
+
24
+ ### Added
25
+
26
+ - `@render_plotly(events=...)` forwards plotly events to Shiny inputs: any of `click`, `hover`, `selected` and `relayout` arrive as `input.<id>_<event>` (namespaced inside a module), carrying plotly's event data cut to what serializes (`points` with each point's scalar fields plus `customdata`, `bbox`, `pointNumbers`; `range` or `lassoPoints` for selections; relayout data as is). A click fires on every click; hover is debounced and becomes `None` when the pointer leaves; a deselect sets `selected` to `None`. Handlers attach once per graph div and survive re-renders. `max_event_points` (default 10 000) caps the points one event carries: above it the value arrives with `"points": None` and `point_count` set, its `range` or `lassoPoints` intact, because a point is about 100 bytes of JSON and a box over a dense trace would otherwise build a message of many megabytes, or one above uvicorn's default 16 MB websocket limit, which closes the session (`make bench-events` measures it; 200 000 selected points are 20.9 MB).
27
+ - `extend_traces(id, data, indices=None, *, max_points=None)`, `restyle(id, update, indices=None)` and `relayout(id, update)`: in-place updates to the figure an output holds, sent as Shiny custom messages and applied in the browser with `Plotly.extendTraces`, `Plotly.restyle` and `Plotly.relayout`. Values go through plotly's encoder; the id is namespaced inside a module; an update sent while the output has no figure drawn is held and applied after its next draw; a re-render replaces the figure, updates included.
28
+ - `enable_compressed_plotly_js(app)` is public: a Core app can serve plotly.js compressed and immutable from its very first request instead of from its first session on.
29
+
30
+ ### Fixed
31
+
32
+ - `output_plotly()` now carries the htmltools fill CSS itself. On a page that did not load it otherwise (`ui.page_fluid` without a card), a fixed-height output (`output_plotly(id, height="200px")`) with a bare `@render_plotly` drew a 400px graph that overflowed the output and whatever sat below it.
33
+ - `output_plotly(id)` (and the Express auto output) did not namespace its id inside a Shiny module, so a `@render_plotly` in a module never found its output.
34
+ - The declared dependency floor was wrong: `plotly>=5.0` and `htmltools>=0.5` could not work (`fig_to_ui` needs `to_html(div_id=...)`, which plotly 5.5 introduced; shiny 1.0 itself needs htmltools 0.5.2). The bounds are now `plotly>=5.5`, `htmltools>=0.5.2`, and CI installs exactly that floor (with shiny 1.0) and runs the whole suite against it, browser tests included (`make check-floor`).
35
+
36
+ ## [0.2.0](https://github.com/rvben/shiny-plotly/compare/v0.1.0...v0.2.0) - 2026-08-19
37
+
38
+ ### Changed
39
+
40
+ - `@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()`.
41
+ - `output_plotly()` carries the plotly.js and helper dependencies itself, so a page-level `plotly_js()` call is no longer needed for it.
42
+ - **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`.
43
+
44
+ ### Added
45
+
46
+ - `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.
47
+ - `shiny-plotly[brotli]` extra.
48
+ - 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.
49
+
50
+ ## [0.1.0] - 2026-08-19
51
+
52
+ ### Added
53
+
54
+ - `@render_plotly`: a `shiny.render.ui` subclass that renders a `plotly.graph_objects.Figure` (or its dict) as HTML drawn with `Plotly.newPlot`. Works in Core and Express, sync or async, bare or with options; `None` renders nothing.
55
+ - `output_plotly(id, width=, height=)`: fill-aware placeholder, a drop-in for `output_widget`. No height fills the container from a 400px basis; an explicit height is fixed and opts out of filling.
56
+ - `fig_to_ui(...)`: the lower-level fragment builder for use from `@render.ui` or any htmltools context.
57
+ - `plotly_js()`: `HTMLDependency` serving plotly.js straight from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`; every figure carries it, so a page-level call is optional.
58
+ - `shiny_plotly_js()` and the `shiny-plotly.js` helper: a `ResizeObserver` keeps each graph sized to its container when the container changes without a window resize, and purges a graph once Shiny replaces the output holding it.
59
+ - `figurewidget_margins=True` fills in unset margin sides with the values shinywidgets applies to a `FigureWidget` (l16/t32/r16/b16), for pixel-identical migrations.
60
+ - `config` (merged over `{"responsive": True}`) and `post_script` (with `{plot_id}` substitution) options for forwarding plotly events to Shiny inputs.
61
+ - 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.
@@ -0,0 +1,422 @@
1
+ Metadata-Version: 2.5
2
+ Name: shiny-plotly
3
+ Version: 0.3.1
4
+ Summary: Render plotly figures in Shiny for Python with plain plotly.js, 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: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering :: Visualization
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: htmltools>=0.5.2
26
+ Requires-Dist: plotly>=5.5
27
+ Requires-Dist: shiny>=1.0
28
+ Provides-Extra: brotli
29
+ Requires-Dist: brotli>=1.1; extra == 'brotli'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # shiny-plotly
33
+
34
+ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https://shiny.posit.co/py/) with plain plotly.js, without the shinywidgets layer.
35
+
36
+ *An independent project, not affiliated with or endorsed by Posit or Plotly.*
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
39
+ [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
40
+
41
+ ```python
42
+ from shiny_plotly import output_plotly, render_plotly
43
+
44
+ # UI
45
+ output_plotly("sales")
46
+
47
+
48
+ # server
49
+ @render_plotly
50
+ def sales():
51
+ return go.Figure(go.Bar(x=months, y=totals))
52
+ ```
53
+
54
+ 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`.
55
+
56
+ ## Why
57
+
58
+ `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:
59
+
60
+ - extra dependencies (`ipywidgets`, `anywidget`, `shinywidgets`) and their JavaScript bundles on every page;
61
+ - a second rendering path next to Shiny's own, with its own quirks around sizing and full screen;
62
+ - figures held as widget state on the server for the life of the session.
63
+
64
+ `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.
65
+
66
+ 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:
67
+
68
+ | | shinywidgets | shiny-plotly |
69
+ | --- | --- | --- |
70
+ | Packages added on top of `shiny` + `plotly` | 24 (38 MB) | 1 (38 kB) |
71
+ | 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) |
72
+ | of which plotly.js over HTTP | 0 (in the websocket) | 1.2 MB (brotli; 1.5 MB gzip) |
73
+ | Repeat visit (warm browser cache) | 5.4 MB, nearly all websocket | 13 kB |
74
+ | Websocket bytes per re-render | 5.4 MB | 10 kB |
75
+ | Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
76
+
77
+ 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 (arbitrary in-place `FigureWidget` mutation, any ipywidget), which the table does not measure; the common in-place updates, appending points and changing trace or layout attributes, are covered by `extend_traces`, `restyle` and `relayout` below. `make bench` reproduces it; `bench/results.json` holds the raw numbers.
78
+
79
+ ## Install
80
+
81
+ ```sh
82
+ uv add shiny-plotly
83
+ # or
84
+ pip install shiny-plotly
85
+ ```
86
+
87
+ Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
88
+
89
+ ## Use
90
+
91
+ ### Core
92
+
93
+ ```python
94
+ import random
95
+ from itertools import accumulate
96
+
97
+ import plotly.graph_objects as go
98
+ from shiny import App, ui
99
+
100
+ from shiny_plotly import output_plotly, render_plotly
101
+
102
+ app_ui = ui.page_fillable(
103
+ ui.input_slider("n", "Points", 10, 500, 100),
104
+ ui.card(
105
+ ui.card_header("Fills the card; try full screen"),
106
+ output_plotly("walk"),
107
+ full_screen=True,
108
+ ),
109
+ )
110
+
111
+
112
+ def server(input, output, session):
113
+ @render_plotly
114
+ def walk():
115
+ rng = random.Random(input.n())
116
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
117
+ return go.Figure(go.Scatter(y=y, mode="lines"))
118
+
119
+
120
+ app = App(app_ui, server)
121
+ ```
122
+
123
+ Anything that is a `plotly.graph_objects.Figure` works, including what `plotly.express` builds (install `plotly[express]` for that).
124
+
125
+ ### Express
126
+
127
+ ```python
128
+ import random
129
+ from itertools import accumulate
130
+
131
+ import plotly.graph_objects as go
132
+ from shiny.express import input, ui
133
+
134
+ from shiny_plotly import render_plotly
135
+
136
+ ui.page_opts(fillable=True)
137
+
138
+ with ui.sidebar():
139
+ ui.input_slider("n", "Points", 10, 500, 100)
140
+
141
+ with ui.card(full_screen=True):
142
+
143
+ @render_plotly
144
+ def walk():
145
+ rng = random.Random(input.n())
146
+ y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
147
+ return go.Figure(go.Scatter(y=y, mode="lines"))
148
+ ```
149
+
150
+ The decorator creates its own output placeholder in Express, just like `@render_widget` does.
151
+
152
+ ### Options
153
+
154
+ ```python
155
+ @render_plotly(
156
+ height="300px", # fixed height; default None fills the container
157
+ width="100%",
158
+ figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
159
+ config={"displaylogo": False},
160
+ events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
161
+ max_event_points=10_000, # above it an event carries the count and range, not the points
162
+ theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
163
+ post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
164
+ )
165
+ def sales(): ...
166
+ ```
167
+
168
+ `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.
169
+
170
+ ### Re-renders, zoom and pan
171
+
172
+ 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 event handlers (from `events=` or `post_script`) and plotly's per-graph state all survive a re-render.
173
+
174
+ 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.
175
+
176
+ ```python
177
+ @render_plotly
178
+ def prices():
179
+ return px.line(frame(), x="date", y="close").update_layout(uirevision="prices")
180
+ ```
181
+
182
+ ### Migrating from shinywidgets
183
+
184
+ | shinywidgets | shiny-plotly |
185
+ | --- | --- |
186
+ | `from shinywidgets import output_widget, render_widget` | `from shiny_plotly import output_plotly, render_plotly` |
187
+ | `output_widget("id")` | `output_plotly("id")` |
188
+ | `output_widget("id", height="300px")` | `output_plotly("id", height="300px")` |
189
+ | `@render_widget` | `@render_plotly` |
190
+ | (FigureWidget margins, applied implicitly) | `@render_plotly(figurewidget_margins=True)` |
191
+
192
+ Three things change on purpose:
193
+
194
+ - **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.
195
+ - **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.
196
+ - **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.
197
+
198
+ ### Sizing
199
+
200
+ The rules mirror `output_widget`:
201
+
202
+ - `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.
203
+ - `height="300px"` (on the decorator or on `output_plotly`): the plot is exactly that tall and opts out of filling.
204
+
205
+ 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.
206
+
207
+ ### Dark mode
208
+
209
+ Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
210
+
211
+ ```python
212
+ app_ui = ui.page_fillable(
213
+ ui.input_dark_mode(),
214
+ ui.card(output_plotly("sales")),
215
+ )
216
+
217
+
218
+ @render_plotly(theme="auto")
219
+ def sales():
220
+ return px.bar(df, x="month", y="total")
221
+ ```
222
+
223
+ `"auto"` pairs plotly's own templates: `"plotly"` in light mode, `"plotly_dark"` in dark. A `(light, dark)` tuple picks different ones, each a registered name, a plotly `Template` object or a template dict:
224
+
225
+ ```python
226
+ @render_plotly(theme=("seaborn", "plotly_dark"))
227
+ def sales(): ...
228
+ ```
229
+
230
+ How it works: both templates travel with the figure, their `paper_bgcolor` and `plot_bgcolor` made transparent so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. The mode is `data-bs-theme` on `<html>`, which is what `ui.input_dark_mode()` maintains, or the OS `prefers-color-scheme` on a page without that attribute. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
231
+
232
+ The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
233
+
234
+ ```python
235
+ app_ui = ui.page_fillable(
236
+ ui.input_dark_mode(id="mode"),
237
+ ui.card(output_plotly("sales")),
238
+ )
239
+
240
+
241
+ @render_plotly
242
+ def sales():
243
+ template = "plotly_dark" if input.mode() == "dark" else "plotly"
244
+ fig = px.bar(df, x="month", y="total", template=template)
245
+ return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
246
+ ```
247
+
248
+ ### Events back to Shiny
249
+
250
+ `events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module. Four are available: `click`, `hover`, `selected` and `relayout`.
251
+
252
+ ```python
253
+ @render_plotly(events=("click", "selected"))
254
+ def scatter(): ...
255
+
256
+
257
+ @render.text
258
+ def click_info():
259
+ if not input.scatter_click.is_set():
260
+ return "Click a point."
261
+ pt = input.scatter_click()["points"][0]
262
+ return f"trace {pt['curveNumber']}, point {pt['pointNumber']}: x={pt['x']}, y={pt['y']}"
263
+ ```
264
+
265
+ What arrives is plotly's own event data, cut to what serializes, the same way Dash cuts it:
266
+
267
+ | event | value of `input.<id>_<event>()` |
268
+ | --- | --- |
269
+ | `click` | `{"points": [...]}`; fires on every click, a repeated one too |
270
+ | `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
271
+ | `selected` | `{"points": [...], "range": {"x": [..], "y": [..]}}` for a box, `lassoPoints` for a lasso; `None` after a double-click deselect; above `max_event_points` the points give way to `point_count` (below) |
272
+ | `relayout` | plotly's relayout data as is: `{"xaxis.range[0]": ..., "xaxis.range[1]": ...}` after a zoom or pan, `{"xaxis.autorange": True, ...}` after a reset, `{"dragmode": "pan"}` from the mode bar, `{"autosize": True}` after a resize |
273
+
274
+ Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
275
+
276
+ #### Dense traces
277
+
278
+ A point is about 100 bytes of JSON, so a box over a dense trace builds a large message, and a large enough one ends the session: uvicorn closes a websocket on a message above 16 MB by default. `max_event_points` (default 10 000) is the most points one event carries. Above it the points stay in the browser and the value says so, with the selection's geometry intact:
279
+
280
+ ```python
281
+ @render_plotly(events="selected")
282
+ def scatter(): ...
283
+
284
+
285
+ @render.text
286
+ def picked():
287
+ sel = input.scatter_selected()
288
+ if sel is None:
289
+ return "Nothing selected."
290
+ if sel["points"] is not None:
291
+ return f"{len(sel['points'])} points"
292
+ # More than max_event_points: {"points": None, "point_count": 120000, "range": {...}}.
293
+ # The data is here, so membership is a filter on the box the user dragged.
294
+ (x0, x1), (y0, y1) = sel["range"]["x"], sel["range"]["y"]
295
+ inside = df[df.x.between(x0, x1) & df.y.between(y0, y1)]
296
+ return f"{sel['point_count']} points, {len(inside)} rows"
297
+ ```
298
+
299
+ The value is never silently cut: `points` is a full list or `None`, and `point_count` is there when it is `None`. A lasso carries `lassoPoints` (the polygon's `x` and `y` lists) instead of `range`. `max_event_points=None` lifts the cap. Measured with `make bench-events` (one `Scattergl` trace, every point box-selected with a real mouse, headless Chromium and the server on the same laptop, 2026-08-19):
300
+
301
+ | points | `max_event_points` | event JSON | mouse up to server |
302
+ | --- | --- | --- | --- |
303
+ | 1 000 | 10 000 | 99 kB | 83 ms |
304
+ | 10 000 | 10 000 | 1.01 MB | 149 ms |
305
+ | 100 000 | 10 000 | 136 B | 24 ms |
306
+ | 100 000 | none | 10.33 MB | 1017 ms |
307
+ | 200 000 | 10 000 | 135 B | 108 ms |
308
+ | 200 000 | none | 20.89 MB | disconnected |
309
+
310
+ `click` and `hover` carry one point per trace under the pointer, so the cap matters for `selected`; hover is also debounced (100 ms), so a pointer sweeping across a dense trace sends one event when it rests, not one per point.
311
+
312
+ For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
313
+
314
+ ```python
315
+ LEGEND_TO_INPUT = """
316
+ document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
317
+ Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
318
+ return true; // let plotly toggle the trace as usual
319
+ });
320
+ """
321
+
322
+
323
+ @render_plotly(post_script=LEGEND_TO_INPUT)
324
+ def scatter(): ...
325
+ ```
326
+
327
+ ### Live updates without a re-render
328
+
329
+ A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `prepend_traces`, `add_traces`, `delete_traces`, `restyle`, `relayout` and `update` call the plotly.js functions of the same names on the graph an output holds. All of them are coroutines, so the effect that calls them is `async def`.
330
+
331
+ ```python
332
+ from shiny_plotly import extend_traces, relayout, restyle
333
+
334
+
335
+ @render_plotly
336
+ def prices():
337
+ return go.Figure(go.Scatter(x=[], y=[], mode="lines")) # the seed; the stream fills it
338
+
339
+
340
+ @reactive.effect
341
+ async def _stream():
342
+ reactive.invalidate_later(1)
343
+ t, v = latest_sample()
344
+ await extend_traces("prices", {"x": [[t]], "y": [[v]]}, max_points=500)
345
+
346
+
347
+ @reactive.effect
348
+ @reactive.event(input.highlight)
349
+ async def _highlight():
350
+ await restyle("prices", {"line.color": "crimson"}, indices=0)
351
+ await relayout("prices", {"title.text": "highlighted"})
352
+ ```
353
+
354
+ - `extend_traces(id, data, indices=None, *, max_points=None)`: `data` maps an array attribute to one sequence of new values per trace, in the order of `indices` (`{"x": [[t]], "y": [[v]]}` appends one point to one trace; `{"y": [[1], [2]]}` with `indices=[0, 1]` one point to each of two). `indices` (an int or a list) defaults to every trace; `max_points` drops the oldest points past that many, for a rolling window.
355
+ - `restyle(id, update, indices=None)`: `update` maps attribute paths to values; `{"marker.color": "red"}` applies to every trace in `indices`, a list value applies per trace (`{"opacity": [0.5, 1]}` with `indices=[0, 1]`).
356
+ - `relayout(id, update)`: layout attribute paths, `{"title.text": "Live"}`, `{"xaxis.range": [0, 10]}`, `{"xaxis.autorange": True}`. With `events="relayout"` on the output, the result comes back as `input.<id>_relayout`, the same as a user's zoom.
357
+ - `update(id, restyle=None, relayout=None, indices=None)`: a restyle and a relayout in one redraw, where separate calls would draw twice. At least one of the two is required; `indices` scopes the trace part.
358
+ - `prepend_traces(id, data, indices=None, *, max_points=None)`: the mirror of `extend_traces`; the new values go in front, and `max_points` drops points from the far end.
359
+ - `add_traces(id, traces, indices=None)`: whole new traces, each a graph object (`go.Scatter(y=[1, 2])`) or a dict (`{"y": [1], "type": "bar"}`); `indices` says where they land in the trace order, appended without it.
360
+ - `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
361
+
362
+ The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
363
+
364
+ ### Lower level
365
+
366
+ - `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`.
367
+ - `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.
368
+ - `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
369
+ - `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
370
+ - `enable_compressed_plotly_js(app)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
371
+ - `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
372
+
373
+ `render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
374
+
375
+ ### plotly.js on the wire
376
+
377
+ 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.
378
+
379
+ ```sh
380
+ uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
381
+ ```
382
+
383
+ 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. A Core app can close that gap by enabling the route as soon as the `App` exists:
384
+
385
+ ```python
386
+ from shiny_plotly import enable_compressed_plotly_js
387
+
388
+ app = App(app_ui, server)
389
+ enable_compressed_plotly_js(app)
390
+ ```
391
+
392
+ 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; `enable_compressed_plotly_js` then returns `False` and adds nothing.
393
+
394
+ ### Shinylive
395
+
396
+ Apps using `shiny-plotly` run under [Shinylive](https://shiny.posit.co/py/get-started/shinylive.html) (pyodide in the browser) as well; list `shiny-plotly` in the app's `requirements.txt` next to `plotly`. There is no HTTP server in the browser and pyodide cannot start threads, so the compression route above stands down under pyodide (`enable_compressed_plotly_js` returns `False`); everything else, rendering, events, in-place updates and themes, is browser-side already. Verified against a real `shinylive export`; `examples/shinylive/` is a ready-to-export app.
397
+
398
+ ## Examples
399
+
400
+ ```sh
401
+ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
402
+ uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
403
+ uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
404
+ uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
405
+ uvx shinylive export examples/shinylive site # the same package, running in the browser
406
+ ```
407
+
408
+ ## Development
409
+
410
+ ```sh
411
+ make sync # uv sync --all-groups
412
+ make browsers # playwright install chromium, once
413
+ make check # lint, typecheck, unit + e2e tests, browser tests, wheel check, floor check
414
+ make bench # the shinywidgets comparison above, on this machine
415
+ make bench-events # what a selection over a dense trace costs, capped and uncapped
416
+ ```
417
+
418
+ `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, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, 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. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
419
+
420
+ ## License
421
+
422
+ MIT. See [LICENSE](LICENSE).