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.
Files changed (36) hide show
  1. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/CHANGELOG.md +12 -0
  2. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/PKG-INFO +42 -8
  3. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/README.md +40 -7
  4. shiny_plotly-0.3.1/examples/dark_app.py +53 -0
  5. shiny_plotly-0.3.1/examples/events_app.py +64 -0
  6. shiny_plotly-0.3.1/examples/shinylive/app.py +33 -0
  7. shiny_plotly-0.3.1/examples/shinylive/requirements.txt +3 -0
  8. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/pyproject.toml +8 -1
  9. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/__init__.py +13 -1
  10. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_render.py +85 -0
  11. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_serve.py +5 -2
  12. shiny_plotly-0.3.1/src/shiny_plotly/_update.py +220 -0
  13. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/www/shiny-plotly.js +75 -5
  14. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/apps.py +54 -1
  15. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/conftest.py +9 -1
  16. shiny_plotly-0.3.1/tests/browser/test_dark_mode.py +99 -0
  17. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_update.py +27 -0
  18. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_compressed_js.py +26 -0
  19. shiny_plotly-0.3.1/tests/test_examples.py +40 -0
  20. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_render_plotly.py +112 -1
  21. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_update.py +75 -5
  22. shiny_plotly-0.3.0/src/shiny_plotly/_update.py +0 -107
  23. shiny_plotly-0.3.0/tests/browser/test_dark_mode.py +0 -41
  24. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/.gitignore +0 -0
  25. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/LICENSE +0 -0
  26. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/examples/core_app.py +0 -0
  27. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/examples/express_app.py +0 -0
  28. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_deps.py +0 -0
  29. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/_html.py +0 -0
  30. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/src/shiny_plotly/py.typed +0 -0
  31. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/__init__.py +0 -0
  32. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_browser.py +0 -0
  33. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/browser/test_events.py +0 -0
  34. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/newplot.py +0 -0
  35. {shiny_plotly-0.3.0 → shiny_plotly-0.3.1}/tests/test_fig_to_ui.py +0 -0
  36. {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.0
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.0`.
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. Give the dark mode switch an id, read it in the render function to pick the template, and make the figure's backgrounds transparent so the card's own background shows through in both modes:
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 `relayout` call the plotly.js functions of the same names on the graph an output holds. All three are coroutines, so the effect that calls them is `async def`.
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.0`.
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. Give the dark mode switch an id, read it in the render function to pick the template, and make the figure's backgrounds transparent so the card's own background shows through in both modes:
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 `relayout` call the plotly.js functions of the same names on the graph an output holds. All three are coroutines, so the effect that calls them is `async def`.
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)
@@ -0,0 +1,3 @@
1
+ # 0.3.0 started a thread at session start, which pyodide cannot do; newer versions skip it.
2
+ shiny-plotly>0.3.0
3
+ plotly
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shiny-plotly"
7
- version = "0.3.0"
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 extend_traces, relayout, restyle
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)