shiny-plotly 0.3.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.
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/CHANGELOG.md +12 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/PKG-INFO +42 -8
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/README.md +40 -7
- shiny_plotly-0.3.1/examples/dark_app.py +53 -0
- shiny_plotly-0.3.1/examples/events_app.py +64 -0
- shiny_plotly-0.3.1/examples/shinylive/app.py +33 -0
- shiny_plotly-0.3.1/examples/shinylive/requirements.txt +3 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/pyproject.toml +8 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/__init__.py +13 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_render.py +85 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_serve.py +5 -2
- shiny_plotly-0.3.1/src/shiny_plotly/_update.py +220 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/www/shiny-plotly.js +75 -5
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/apps.py +54 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/conftest.py +9 -1
- shiny_plotly-0.3.1/tests/browser/test_dark_mode.py +99 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_update.py +27 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_compressed_js.py +26 -0
- shiny_plotly-0.3.1/tests/test_examples.py +40 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_render_plotly.py +112 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_update.py +75 -5
- shiny_plotly-0.3.0/src/shiny_plotly/_update.py +0 -107
- shiny_plotly-0.3.0/tests/browser/test_dark_mode.py +0 -41
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/.gitignore +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/LICENSE +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/examples/core_app.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_browser.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_events.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
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
|
+
|
|
10
22
|
## [0.3.0](https://github.com/rvben/shiny-plotly/compare/v0.2.0...v0.3.0) - 2026-08-20
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
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
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
23
|
Classifier: Typing :: Typed
|
|
23
24
|
Requires-Python: >=3.10
|
|
@@ -83,7 +84,7 @@ uv add shiny-plotly
|
|
|
83
84
|
pip install shiny-plotly
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.
|
|
87
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
|
|
87
88
|
|
|
88
89
|
## Use
|
|
89
90
|
|
|
@@ -158,6 +159,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
158
159
|
config={"displaylogo": False},
|
|
159
160
|
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
160
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)
|
|
161
163
|
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
162
164
|
)
|
|
163
165
|
def sales(): ...
|
|
@@ -204,7 +206,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
204
206
|
|
|
205
207
|
### Dark mode
|
|
206
208
|
|
|
207
|
-
Plotly does not follow Bootstrap's color mode by itself.
|
|
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`:
|
|
208
233
|
|
|
209
234
|
```python
|
|
210
235
|
app_ui = ui.page_fillable(
|
|
@@ -220,8 +245,6 @@ def sales():
|
|
|
220
245
|
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
221
246
|
```
|
|
222
247
|
|
|
223
|
-
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
224
|
-
|
|
225
248
|
### Events back to Shiny
|
|
226
249
|
|
|
227
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`.
|
|
@@ -303,7 +326,7 @@ def scatter(): ...
|
|
|
303
326
|
|
|
304
327
|
### Live updates without a re-render
|
|
305
328
|
|
|
306
|
-
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`, `restyle` and `
|
|
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`.
|
|
307
330
|
|
|
308
331
|
```python
|
|
309
332
|
from shiny_plotly import extend_traces, relayout, restyle
|
|
@@ -331,6 +354,10 @@ async def _highlight():
|
|
|
331
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.
|
|
332
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]`).
|
|
333
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`.
|
|
334
361
|
|
|
335
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.
|
|
336
363
|
|
|
@@ -364,11 +391,18 @@ enable_compressed_plotly_js(app)
|
|
|
364
391
|
|
|
365
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.
|
|
366
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
|
+
|
|
367
398
|
## Examples
|
|
368
399
|
|
|
369
400
|
```sh
|
|
370
|
-
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
371
|
-
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
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
|
|
372
406
|
```
|
|
373
407
|
|
|
374
408
|
## Development
|
|
@@ -53,7 +53,7 @@ uv add shiny-plotly
|
|
|
53
53
|
pip install shiny-plotly
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.
|
|
56
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
|
|
57
57
|
|
|
58
58
|
## Use
|
|
59
59
|
|
|
@@ -128,6 +128,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
128
128
|
config={"displaylogo": False},
|
|
129
129
|
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
130
130
|
max_event_points=10_000, # above it an event carries the count and range, not the points
|
|
131
|
+
theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
|
|
131
132
|
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
132
133
|
)
|
|
133
134
|
def sales(): ...
|
|
@@ -174,7 +175,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
174
175
|
|
|
175
176
|
### Dark mode
|
|
176
177
|
|
|
177
|
-
Plotly does not follow Bootstrap's color mode by itself.
|
|
178
|
+
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:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
app_ui = ui.page_fillable(
|
|
182
|
+
ui.input_dark_mode(),
|
|
183
|
+
ui.card(output_plotly("sales")),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@render_plotly(theme="auto")
|
|
188
|
+
def sales():
|
|
189
|
+
return px.bar(df, x="month", y="total")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`"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:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
@render_plotly(theme=("seaborn", "plotly_dark"))
|
|
196
|
+
def sales(): ...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
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.
|
|
200
|
+
|
|
201
|
+
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`:
|
|
178
202
|
|
|
179
203
|
```python
|
|
180
204
|
app_ui = ui.page_fillable(
|
|
@@ -190,8 +214,6 @@ def sales():
|
|
|
190
214
|
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
191
215
|
```
|
|
192
216
|
|
|
193
|
-
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
194
|
-
|
|
195
217
|
### Events back to Shiny
|
|
196
218
|
|
|
197
219
|
`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`.
|
|
@@ -273,7 +295,7 @@ def scatter(): ...
|
|
|
273
295
|
|
|
274
296
|
### Live updates without a re-render
|
|
275
297
|
|
|
276
|
-
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`, `restyle` and `
|
|
298
|
+
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`.
|
|
277
299
|
|
|
278
300
|
```python
|
|
279
301
|
from shiny_plotly import extend_traces, relayout, restyle
|
|
@@ -301,6 +323,10 @@ async def _highlight():
|
|
|
301
323
|
- `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.
|
|
302
324
|
- `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]`).
|
|
303
325
|
- `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.
|
|
326
|
+
- `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.
|
|
327
|
+
- `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.
|
|
328
|
+
- `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.
|
|
329
|
+
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
304
330
|
|
|
305
331
|
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.
|
|
306
332
|
|
|
@@ -334,11 +360,18 @@ enable_compressed_plotly_js(app)
|
|
|
334
360
|
|
|
335
361
|
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.
|
|
336
362
|
|
|
363
|
+
### Shinylive
|
|
364
|
+
|
|
365
|
+
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.
|
|
366
|
+
|
|
337
367
|
## Examples
|
|
338
368
|
|
|
339
369
|
```sh
|
|
340
|
-
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
341
|
-
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
370
|
+
uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
|
|
371
|
+
uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
|
|
372
|
+
uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
|
|
373
|
+
uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
|
|
374
|
+
uvx shinylive export examples/shinylive site # the same package, running in the browser
|
|
342
375
|
```
|
|
343
376
|
|
|
344
377
|
## Development
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Dark mode example: theme="auto" follows the page's color mode in the browser.
|
|
2
|
+
|
|
3
|
+
Run with: uv run --with shiny-plotly shiny run examples/dark_app.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import plotly.graph_objects as go
|
|
7
|
+
from shiny import App, Inputs, Outputs, Session, ui
|
|
8
|
+
|
|
9
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
10
|
+
|
|
11
|
+
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
|
|
12
|
+
TOTALS = [12, 17, 9, 20, 14, 23]
|
|
13
|
+
|
|
14
|
+
app_ui = ui.page_fillable(
|
|
15
|
+
ui.input_dark_mode(),
|
|
16
|
+
ui.layout_columns(
|
|
17
|
+
ui.card(
|
|
18
|
+
ui.card_header("theme='auto': plotly in light, plotly_dark in dark"),
|
|
19
|
+
output_plotly("auto_themed"),
|
|
20
|
+
full_screen=True,
|
|
21
|
+
),
|
|
22
|
+
ui.card(
|
|
23
|
+
ui.card_header("A custom (light, dark) pair"),
|
|
24
|
+
output_plotly("pair_themed"),
|
|
25
|
+
full_screen=True,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
title="shiny-plotly dark mode",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Any registered template name, plotly Template object or template dict works in the pair.
|
|
32
|
+
DARK_TEMPLATE = {
|
|
33
|
+
"layout": {
|
|
34
|
+
"font": {"color": "#f2f5fa"},
|
|
35
|
+
"colorway": ["#66c2a5", "#fc8d62", "#8da0cb"],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
41
|
+
# The flip happens in the browser with Plotly.relayout: no server round-trip, and it
|
|
42
|
+
# works even while the server is busy. Both templates travel with the figure, their
|
|
43
|
+
# backgrounds made transparent so the card shows through in both modes.
|
|
44
|
+
@render_plotly(theme="auto")
|
|
45
|
+
def auto_themed():
|
|
46
|
+
return go.Figure(go.Bar(x=MONTHS, y=TOTALS))
|
|
47
|
+
|
|
48
|
+
@render_plotly(theme=("seaborn", DARK_TEMPLATE))
|
|
49
|
+
def pair_themed():
|
|
50
|
+
return go.Figure(go.Scatter(x=MONTHS, y=TOTALS, mode="lines+markers"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = App(app_ui, server)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Events example: clicks and box selections arrive as Shiny inputs.
|
|
2
|
+
|
|
3
|
+
The trace holds 50 000 points, above the default max_event_points of 10 000, so a big
|
|
4
|
+
selection arrives as a count plus the box's geometry and the server recounts membership
|
|
5
|
+
from its own data, the pattern the README describes for dense traces.
|
|
6
|
+
|
|
7
|
+
Run with: uv run --with shiny-plotly shiny run examples/events_app.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from shiny import App, Inputs, Outputs, Session, render, ui
|
|
14
|
+
|
|
15
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
16
|
+
|
|
17
|
+
rng = random.Random(7)
|
|
18
|
+
N = 50_000
|
|
19
|
+
X = [rng.gauss(0, 1) for _ in range(N)]
|
|
20
|
+
Y = [rng.gauss(0, 1) for _ in range(N)]
|
|
21
|
+
|
|
22
|
+
app_ui = ui.page_fillable(
|
|
23
|
+
ui.card(
|
|
24
|
+
ui.card_header("Drag a box; click a point"),
|
|
25
|
+
output_plotly("cloud"),
|
|
26
|
+
full_screen=True,
|
|
27
|
+
),
|
|
28
|
+
ui.output_text("selection_info"),
|
|
29
|
+
ui.output_text("click_info"),
|
|
30
|
+
title="shiny-plotly events",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
35
|
+
@render_plotly(events=("click", "selected"))
|
|
36
|
+
def cloud():
|
|
37
|
+
fig = go.Figure(go.Scattergl(x=X, y=Y, mode="markers", marker={"size": 3}))
|
|
38
|
+
return fig.update_layout(dragmode="select")
|
|
39
|
+
|
|
40
|
+
@render.text
|
|
41
|
+
def selection_info():
|
|
42
|
+
if not input.cloud_selected.is_set():
|
|
43
|
+
return "Selection: none yet."
|
|
44
|
+
sel = input.cloud_selected()
|
|
45
|
+
if sel is None:
|
|
46
|
+
return "Selection: cleared."
|
|
47
|
+
if sel["points"] is not None:
|
|
48
|
+
return f"Selection: {len(sel['points'])} points arrived with the event."
|
|
49
|
+
# Above max_event_points the geometry arrives instead of the points; the data
|
|
50
|
+
# lives here, so membership is a filter on the box the user dragged.
|
|
51
|
+
x0, x1 = sorted(sel["range"]["x"])
|
|
52
|
+
y0, y1 = sorted(sel["range"]["y"])
|
|
53
|
+
inside = sum(1 for x, y in zip(X, Y, strict=True) if x0 <= x <= x1 and y0 <= y <= y1)
|
|
54
|
+
return f"Selection: {sel['point_count']} points; recounted {inside} on the server."
|
|
55
|
+
|
|
56
|
+
@render.text
|
|
57
|
+
def click_info():
|
|
58
|
+
if not input.cloud_click.is_set():
|
|
59
|
+
return "Click: none yet."
|
|
60
|
+
pt = input.cloud_click()["points"][0]
|
|
61
|
+
return f"Click: point {pt['pointNumber']} at x={pt['x']:.2f}, y={pt['y']:.2f}."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app = App(app_ui, server)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shinylive example: the whole app runs in the browser via pyodide, no server.
|
|
2
|
+
|
|
3
|
+
Export this directory to a static site and serve it:
|
|
4
|
+
|
|
5
|
+
uvx shinylive export examples/shinylive site
|
|
6
|
+
python -m http.server 8000 -d site
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from itertools import accumulate
|
|
11
|
+
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from shiny import App, Inputs, Outputs, Session, ui
|
|
14
|
+
|
|
15
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
16
|
+
|
|
17
|
+
app_ui = ui.page_fillable(
|
|
18
|
+
ui.input_slider("n", "Points", min=10, max=500, value=100),
|
|
19
|
+
ui.card(output_plotly("walk"), full_screen=True),
|
|
20
|
+
title="shiny-plotly on shinylive",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
25
|
+
# theme="auto" follows the embedding page's color mode, OS preference included.
|
|
26
|
+
@render_plotly(theme="auto")
|
|
27
|
+
def walk():
|
|
28
|
+
rng = random.Random(input.n())
|
|
29
|
+
y = list(accumulate(rng.gauss(0, 1) for _ in range(input.n())))
|
|
30
|
+
return go.Figure(go.Scatter(y=y, mode="lines"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
app = App(app_ui, server)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
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"
|
|
@@ -22,6 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: 3.11",
|
|
23
23
|
"Programming Language :: Python :: 3.12",
|
|
24
24
|
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
25
26
|
"Topic :: Scientific/Engineering :: Visualization",
|
|
26
27
|
"Typing :: Typed",
|
|
27
28
|
]
|
|
@@ -59,6 +60,12 @@ dev = [
|
|
|
59
60
|
"brotli>=1.1",
|
|
60
61
|
]
|
|
61
62
|
|
|
63
|
+
# uv's default cache keys for a local build are the metadata files only, so a source-only
|
|
64
|
+
# change would keep serving the previously built wheel to `uv pip install .` (check-floor
|
|
65
|
+
# once tested a stale build that way). Key the cache on the shipped source too.
|
|
66
|
+
[tool.uv]
|
|
67
|
+
cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*" }]
|
|
68
|
+
|
|
62
69
|
[tool.hatch.build.targets.wheel]
|
|
63
70
|
packages = ["src/shiny_plotly"]
|
|
64
71
|
|
|
@@ -4,19 +4,31 @@ from ._deps import __version__, plotly_js, shiny_plotly_js
|
|
|
4
4
|
from ._html import FIGUREWIDGET_MARGINS, fig_to_ui
|
|
5
5
|
from ._render import DEFAULT_MAX_EVENT_POINTS, output_plotly, render_plotly
|
|
6
6
|
from ._serve import enable_compressed_plotly_js
|
|
7
|
-
from ._update import
|
|
7
|
+
from ._update import (
|
|
8
|
+
add_traces,
|
|
9
|
+
delete_traces,
|
|
10
|
+
extend_traces,
|
|
11
|
+
prepend_traces,
|
|
12
|
+
relayout,
|
|
13
|
+
restyle,
|
|
14
|
+
update,
|
|
15
|
+
)
|
|
8
16
|
|
|
9
17
|
__all__ = (
|
|
10
18
|
"DEFAULT_MAX_EVENT_POINTS",
|
|
11
19
|
"FIGUREWIDGET_MARGINS",
|
|
12
20
|
"__version__",
|
|
21
|
+
"add_traces",
|
|
22
|
+
"delete_traces",
|
|
13
23
|
"enable_compressed_plotly_js",
|
|
14
24
|
"extend_traces",
|
|
15
25
|
"fig_to_ui",
|
|
16
26
|
"output_plotly",
|
|
17
27
|
"plotly_js",
|
|
28
|
+
"prepend_traces",
|
|
18
29
|
"relayout",
|
|
19
30
|
"render_plotly",
|
|
20
31
|
"restyle",
|
|
21
32
|
"shiny_plotly_js",
|
|
33
|
+
"update",
|
|
22
34
|
)
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
|
|
6
6
|
import plotly.io as pio
|
|
7
7
|
from htmltools import Tag, css, tags
|
|
8
|
+
from plotly.io.json import to_json_plotly
|
|
8
9
|
from shiny.module import resolve_id
|
|
9
10
|
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
|
|
10
11
|
from shiny.ui.fill import as_fill_item, as_fillable_container
|
|
@@ -40,6 +41,59 @@ def normalize_events(events: str | Iterable[str] | None) -> tuple[str, ...]:
|
|
|
40
41
|
return tuple(name for name in EVENTS if name in names)
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
# One theme template: a registered name ("plotly_dark"), a plotly Template object, or a
|
|
45
|
+
# template dict ({"layout": {...}, "data": {...}}).
|
|
46
|
+
TemplateSpec = str | Mapping[str, Any] | Any
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ThemePair = tuple[TemplateSpec, TemplateSpec]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_theme(theme: str | ThemePair | None) -> ThemePair | None:
|
|
53
|
+
"""``None``, or a validated (light, dark) pair of template specs."""
|
|
54
|
+
if theme is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(theme, str):
|
|
57
|
+
if theme == "auto":
|
|
58
|
+
return ("plotly", "plotly_dark")
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f'theme must be "auto" or a (light, dark) pair of templates, got {theme!r}; '
|
|
61
|
+
"for one template in both modes, set layout.template on the figure instead"
|
|
62
|
+
)
|
|
63
|
+
pair = tuple(theme)
|
|
64
|
+
if len(pair) != 2:
|
|
65
|
+
raise ValueError(f"theme must be a (light, dark) pair of templates, got {len(pair)}")
|
|
66
|
+
for spec in pair:
|
|
67
|
+
if isinstance(spec, str):
|
|
68
|
+
if spec not in pio.templates:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"unknown template name {spec!r}; the registered names are "
|
|
71
|
+
+ ", ".join(sorted(pio.templates))
|
|
72
|
+
)
|
|
73
|
+
elif not isinstance(spec, Mapping) and not callable(getattr(spec, "to_plotly_json", None)):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"a theme template must be a registered name, a plotly Template object "
|
|
76
|
+
f"or a template dict, got {type(spec).__name__}"
|
|
77
|
+
)
|
|
78
|
+
return pair
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _template_json(spec: TemplateSpec) -> dict[str, Any]:
|
|
82
|
+
"""The template as a plain dict, backgrounds made transparent in a copy."""
|
|
83
|
+
template: Any = pio.templates[spec] if isinstance(spec, str) else spec
|
|
84
|
+
to_json = getattr(template, "to_plotly_json", None)
|
|
85
|
+
raw: Any = to_json() if callable(to_json) else template
|
|
86
|
+
out: dict[str, Any] = dict(raw)
|
|
87
|
+
layout = dict(out.get("layout") or {})
|
|
88
|
+
# The page's own background shows through, so the graph matches the surface it sits
|
|
89
|
+
# on in both modes. A paper_bgcolor or plot_bgcolor set on the figure's layout still
|
|
90
|
+
# wins, as figure values always do over template values.
|
|
91
|
+
layout["paper_bgcolor"] = "rgba(0,0,0,0)"
|
|
92
|
+
layout["plot_bgcolor"] = "rgba(0,0,0,0)"
|
|
93
|
+
out["layout"] = layout
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
43
97
|
def normalize_max_event_points(value: int | None) -> int | None:
|
|
44
98
|
if value is None:
|
|
45
99
|
return None
|
|
@@ -136,6 +190,19 @@ class render_plotly(Renderer[Figure]):
|
|
|
136
190
|
data is. At 10 000 points an event is about 1 MB; ``None`` lifts the cap, and a
|
|
137
191
|
selection of 150 000 points or more then exceeds the 16 MB websocket message
|
|
138
192
|
limit uvicorn applies by default, which closes the session.
|
|
193
|
+
theme
|
|
194
|
+
Make the figure follow the page's color mode, in the browser, with no server
|
|
195
|
+
round-trip. ``"auto"`` uses plotly's own pair: the ``"plotly"`` template in light
|
|
196
|
+
mode and ``"plotly_dark"`` in dark. A ``(light, dark)`` pair picks the templates,
|
|
197
|
+
each a registered name (``"seaborn"``), a plotly ``Template`` object or a template
|
|
198
|
+
dict. Both templates travel with the figure, with transparent ``paper_bgcolor``
|
|
199
|
+
and ``plot_bgcolor`` so the page shows through (a figure-level background still
|
|
200
|
+
wins); a template the figure baked in via ``layout.template`` is dropped. The
|
|
201
|
+
browser applies the mode's template before the first draw and switches it with
|
|
202
|
+
``Plotly.relayout`` when the mode flips: it follows ``data-bs-theme`` on ``<html>``
|
|
203
|
+
(what ``ui.input_dark_mode()`` sets) when present, the OS ``prefers-color-scheme``
|
|
204
|
+
otherwise. Template names resolve when the decorator runs; an unknown name raises
|
|
205
|
+
right there. Default ``None``: the figure's own template, fixed.
|
|
139
206
|
"""
|
|
140
207
|
|
|
141
208
|
def __init__(
|
|
@@ -149,6 +216,7 @@ class render_plotly(Renderer[Figure]):
|
|
|
149
216
|
post_script: str | None = None,
|
|
150
217
|
events: str | Iterable[str] | None = None,
|
|
151
218
|
max_event_points: int | None = DEFAULT_MAX_EVENT_POINTS,
|
|
219
|
+
theme: str | ThemePair | None = None,
|
|
152
220
|
) -> None:
|
|
153
221
|
self.height = height
|
|
154
222
|
self.width = width
|
|
@@ -157,6 +225,16 @@ class render_plotly(Renderer[Figure]):
|
|
|
157
225
|
self.post_script = post_script
|
|
158
226
|
self.events = normalize_events(events)
|
|
159
227
|
self.max_event_points = normalize_max_event_points(max_event_points)
|
|
228
|
+
self.theme = normalize_theme(theme)
|
|
229
|
+
if self.theme is None:
|
|
230
|
+
self._themes_json = None
|
|
231
|
+
else:
|
|
232
|
+
# Resolved and serialized once, here, so an unknown name fails at decoration
|
|
233
|
+
# time and a render costs nothing extra.
|
|
234
|
+
light, dark = self.theme
|
|
235
|
+
self._themes_json = to_json_plotly(
|
|
236
|
+
{"light": _template_json(light), "dark": _template_json(dark)}
|
|
237
|
+
)
|
|
160
238
|
# Registers _fn (sets output_id from its name) when used as a bare decorator.
|
|
161
239
|
super().__init__(_fn) # type: ignore[arg-type]
|
|
162
240
|
|
|
@@ -175,6 +253,12 @@ class render_plotly(Renderer[Figure]):
|
|
|
175
253
|
fig_dict = as_fig_dict(value)
|
|
176
254
|
if self.figurewidget_margins:
|
|
177
255
|
fill_in_margins(fig_dict)
|
|
256
|
+
if self._themes_json is not None:
|
|
257
|
+
# The browser picks the mode's template; the one the figure baked in at
|
|
258
|
+
# construction would only add dead weight and a flash of the wrong theme.
|
|
259
|
+
layout = fig_dict.get("layout")
|
|
260
|
+
if isinstance(layout, dict):
|
|
261
|
+
layout.pop("template", None)
|
|
178
262
|
return {
|
|
179
263
|
# Serialised by plotly, not Shiny: numpy and pandas values, datetimes and the
|
|
180
264
|
# compact base64 array encoding only work through plotly's encoder.
|
|
@@ -185,4 +269,5 @@ class render_plotly(Renderer[Figure]):
|
|
|
185
269
|
"post_script": self.post_script,
|
|
186
270
|
"events": list(self.events),
|
|
187
271
|
"max_event_points": self.max_event_points,
|
|
272
|
+
"themes": self._themes_json,
|
|
188
273
|
}
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import gzip
|
|
21
21
|
import hashlib
|
|
22
22
|
import os
|
|
23
|
+
import sys
|
|
23
24
|
import threading
|
|
24
25
|
from collections.abc import Iterator
|
|
25
26
|
from pathlib import Path
|
|
@@ -161,9 +162,11 @@ def enable_compressed_plotly_js(app: object) -> bool:
|
|
|
161
162
|
|
|
162
163
|
Returns True when the route was added, False when it was already there, ``app`` is
|
|
163
164
|
not a Shiny app, or ``SHINY_PLOTLY_NO_COMPRESS`` is set in the environment (the escape
|
|
164
|
-
hatch for deployments that want Shiny's own static serving untouched).
|
|
165
|
+
hatch for deployments that want Shiny's own static serving untouched). Under pyodide
|
|
166
|
+
(shinylive) it is False too: threads cannot start there, and the browser loads assets
|
|
167
|
+
from the shinylive bundle rather than over HTTP, so there is nothing to compress.
|
|
165
168
|
"""
|
|
166
|
-
if os.environ.get("SHINY_PLOTLY_NO_COMPRESS"):
|
|
169
|
+
if os.environ.get("SHINY_PLOTLY_NO_COMPRESS") or sys.platform == "emscripten":
|
|
167
170
|
return False
|
|
168
171
|
starlette_app = getattr(app, "starlette_app", None)
|
|
169
172
|
lib_prefix = getattr(app, "lib_prefix", None)
|