shiny-plotly 0.3.0__tar.gz → 0.3.2__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 (39) hide show
  1. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/.gitignore +1 -0
  2. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/CHANGELOG.md +20 -0
  3. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/PKG-INFO +55 -14
  4. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/README.md +53 -13
  5. shiny_plotly-0.3.2/examples/dark_app.py +53 -0
  6. shiny_plotly-0.3.2/examples/events_app.py +64 -0
  7. shiny_plotly-0.3.2/examples/shinylive/app.py +114 -0
  8. shiny_plotly-0.3.2/examples/shinylive/requirements.txt +3 -0
  9. shiny_plotly-0.3.2/examples/streaming_app.py +69 -0
  10. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/pyproject.toml +9 -2
  11. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/__init__.py +13 -1
  12. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_render.py +108 -7
  13. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_serve.py +5 -2
  14. shiny_plotly-0.3.2/src/shiny_plotly/_update.py +220 -0
  15. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/www/shiny-plotly.js +101 -5
  16. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/apps.py +78 -1
  17. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/conftest.py +24 -1
  18. shiny_plotly-0.3.2/tests/browser/test_dark_mode.py +99 -0
  19. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/test_events.py +53 -0
  20. shiny_plotly-0.3.2/tests/browser/test_shinylive_demo.py +48 -0
  21. shiny_plotly-0.3.2/tests/browser/test_streaming_example.py +39 -0
  22. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/test_update.py +27 -0
  23. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_compressed_js.py +26 -0
  24. shiny_plotly-0.3.2/tests/test_examples.py +41 -0
  25. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_render_plotly.py +122 -2
  26. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_update.py +75 -5
  27. shiny_plotly-0.3.0/src/shiny_plotly/_update.py +0 -107
  28. shiny_plotly-0.3.0/tests/browser/test_dark_mode.py +0 -41
  29. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/LICENSE +0 -0
  30. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/examples/core_app.py +0 -0
  31. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/examples/express_app.py +0 -0
  32. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_deps.py +0 -0
  33. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_html.py +0 -0
  34. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/py.typed +0 -0
  35. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/__init__.py +0 -0
  36. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/test_browser.py +0 -0
  37. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/newplot.py +0 -0
  38. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_fig_to_ui.py +0 -0
  39. {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_plotly_js.py +0 -0
@@ -7,3 +7,4 @@ __pycache__/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
9
  tmp/
10
+ site/
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
11
+
12
+ ### Added
13
+
14
+ - Three more forwardable events: `doubleclick` arrives as a running count of plot-area double-clicks (plotly hands the event no data; the count's change invalidates the input); `legendclick` and `legenddoubleclick` arrive as the clicked trace's `curve_number`, `expanded_index`, `name` and pre-toggle `visible`, plus `label` for trace types whose legend items are labels. The default toggle and isolate behaviors still happen.
15
+ - `examples/streaming_app.py`: a live rolling window fed by `extend_traces`, with a pause switch and a window slider; a browser test drives the example file itself and proves the stream flows and pauses.
16
+ - A live demo at https://rvben.github.io/shiny-plotly/: `examples/shinylive/app.py` grew into a two-tab showcase (a streaming figure, click and box-select events, a dark mode toggle), deployed to GitHub Pages by a workflow that builds the site from the checkout's own wheel (`make site`) and gates the deploy on a headless Chromium watching both tabs render (`make site-check`). The committed example still installs from PyPI, so `uvx shinylive export examples/shinylive site` keeps working as documented.
17
+
18
+ ## [0.3.1](https://github.com/rvben/shiny-plotly/compare/v0.3.0...v0.3.1) - 2026-08-20
19
+
20
+ ### Added
21
+
22
+ - `@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.
23
+ - 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.
24
+ - 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.
25
+
26
+ ### Fixed
27
+
28
+ - 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.
29
+
10
30
  ## [0.3.0](https://github.com/rvben/shiny-plotly/compare/v0.2.0...v0.3.0) - 2026-08-20
11
31
 
12
32
  ### 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.2
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
@@ -37,6 +38,8 @@ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https:
37
38
  [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
38
39
  [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
39
40
 
41
+ **[Try the live demo](https://rvben.github.io/shiny-plotly/)**: a streaming figure and forwarded plotly events, running entirely in your browser through shinylive.
42
+
40
43
  ```python
41
44
  from shiny_plotly import output_plotly, render_plotly
42
45
 
@@ -83,7 +86,7 @@ uv add shiny-plotly
83
86
  pip install shiny-plotly
84
87
  ```
85
88
 
86
- Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
89
+ Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
87
90
 
88
91
  ## Use
89
92
 
@@ -158,6 +161,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
158
161
  config={"displaylogo": False},
159
162
  events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
160
163
  max_event_points=10_000, # above it an event carries the count and range, not the points
164
+ theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
161
165
  post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
162
166
  )
163
167
  def sales(): ...
@@ -204,7 +208,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
204
208
 
205
209
  ### Dark mode
206
210
 
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:
211
+ 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:
212
+
213
+ ```python
214
+ app_ui = ui.page_fillable(
215
+ ui.input_dark_mode(),
216
+ ui.card(output_plotly("sales")),
217
+ )
218
+
219
+
220
+ @render_plotly(theme="auto")
221
+ def sales():
222
+ return px.bar(df, x="month", y="total")
223
+ ```
224
+
225
+ `"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:
226
+
227
+ ```python
228
+ @render_plotly(theme=("seaborn", "plotly_dark"))
229
+ def sales(): ...
230
+ ```
231
+
232
+ 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.
233
+
234
+ 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
235
 
209
236
  ```python
210
237
  app_ui = ui.page_fillable(
@@ -220,11 +247,9 @@ def sales():
220
247
  return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
221
248
  ```
222
249
 
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
250
  ### Events back to Shiny
226
251
 
227
- `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`.
252
+ `events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module. Seven are available: `click`, `doubleclick`, `hover`, `selected`, `relayout`, `legendclick` and `legenddoubleclick`.
228
253
 
229
254
  ```python
230
255
  @render_plotly(events=("click", "selected"))
@@ -247,6 +272,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
247
272
  | `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
248
273
  | `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) |
249
274
  | `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 |
275
+ | `doubleclick` | a running count of double-clicks on the plot area (the gesture that resets the axes); plotly hands the event no data, and the count's change is what invalidates the input |
276
+ | `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
277
+ | `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
250
278
 
251
279
  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.
252
280
 
@@ -289,21 +317,20 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
289
317
  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.
290
318
 
291
319
  ```python
292
- LEGEND_TO_INPUT = """
293
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
294
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
295
- return true; // let plotly toggle the trace as usual
320
+ ANNOTATION_TO_INPUT = """
321
+ document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
322
+ Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
296
323
  });
297
324
  """
298
325
 
299
326
 
300
- @render_plotly(post_script=LEGEND_TO_INPUT)
327
+ @render_plotly(post_script=ANNOTATION_TO_INPUT)
301
328
  def scatter(): ...
302
329
  ```
303
330
 
304
331
  ### Live updates without a re-render
305
332
 
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`.
333
+ 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
334
 
308
335
  ```python
309
336
  from shiny_plotly import extend_traces, relayout, restyle
@@ -331,9 +358,15 @@ async def _highlight():
331
358
  - `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
359
  - `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
360
  - `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.
361
+ - `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.
362
+ - `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.
363
+ - `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.
364
+ - `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
334
365
 
335
366
  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
367
 
368
+ A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
369
+
337
370
  ### Lower level
338
371
 
339
372
  - `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`.
@@ -364,11 +397,19 @@ enable_compressed_plotly_js(app)
364
397
 
365
398
  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
399
 
400
+ ### Shinylive
401
+
402
+ 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, and its deployed copy is the [live demo](https://rvben.github.io/shiny-plotly/). The Pages workflow builds that demo from the current checkout's wheel (`make site`) and deploys only after a headless Chromium has watched both tabs render (`make site-check`).
403
+
367
404
  ## Examples
368
405
 
369
406
  ```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
407
+ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
408
+ uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
409
+ uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
410
+ uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
411
+ uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
412
+ uvx shinylive export examples/shinylive site # the same package, running in the browser
372
413
  ```
373
414
 
374
415
  ## Development
@@ -7,6 +7,8 @@ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https:
7
7
  [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
8
8
  [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
9
9
 
10
+ **[Try the live demo](https://rvben.github.io/shiny-plotly/)**: a streaming figure and forwarded plotly events, running entirely in your browser through shinylive.
11
+
10
12
  ```python
11
13
  from shiny_plotly import output_plotly, render_plotly
12
14
 
@@ -53,7 +55,7 @@ uv add shiny-plotly
53
55
  pip install shiny-plotly
54
56
  ```
55
57
 
56
- Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.0`.
58
+ Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
57
59
 
58
60
  ## Use
59
61
 
@@ -128,6 +130,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
128
130
  config={"displaylogo": False},
129
131
  events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
130
132
  max_event_points=10_000, # above it an event carries the count and range, not the points
133
+ theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
131
134
  post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
132
135
  )
133
136
  def sales(): ...
@@ -174,7 +177,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
174
177
 
175
178
  ### Dark mode
176
179
 
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:
180
+ 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:
181
+
182
+ ```python
183
+ app_ui = ui.page_fillable(
184
+ ui.input_dark_mode(),
185
+ ui.card(output_plotly("sales")),
186
+ )
187
+
188
+
189
+ @render_plotly(theme="auto")
190
+ def sales():
191
+ return px.bar(df, x="month", y="total")
192
+ ```
193
+
194
+ `"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:
195
+
196
+ ```python
197
+ @render_plotly(theme=("seaborn", "plotly_dark"))
198
+ def sales(): ...
199
+ ```
200
+
201
+ 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.
202
+
203
+ 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
204
 
179
205
  ```python
180
206
  app_ui = ui.page_fillable(
@@ -190,11 +216,9 @@ def sales():
190
216
  return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
191
217
  ```
192
218
 
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
219
  ### Events back to Shiny
196
220
 
197
- `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`.
221
+ `events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module. Seven are available: `click`, `doubleclick`, `hover`, `selected`, `relayout`, `legendclick` and `legenddoubleclick`.
198
222
 
199
223
  ```python
200
224
  @render_plotly(events=("click", "selected"))
@@ -217,6 +241,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
217
241
  | `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
218
242
  | `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) |
219
243
  | `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 |
244
+ | `doubleclick` | a running count of double-clicks on the plot area (the gesture that resets the axes); plotly hands the event no data, and the count's change is what invalidates the input |
245
+ | `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
246
+ | `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
220
247
 
221
248
  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.
222
249
 
@@ -259,21 +286,20 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
259
286
  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.
260
287
 
261
288
  ```python
262
- LEGEND_TO_INPUT = """
263
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
264
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
265
- return true; // let plotly toggle the trace as usual
289
+ ANNOTATION_TO_INPUT = """
290
+ document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
291
+ Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
266
292
  });
267
293
  """
268
294
 
269
295
 
270
- @render_plotly(post_script=LEGEND_TO_INPUT)
296
+ @render_plotly(post_script=ANNOTATION_TO_INPUT)
271
297
  def scatter(): ...
272
298
  ```
273
299
 
274
300
  ### Live updates without a re-render
275
301
 
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`.
302
+ 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
303
 
278
304
  ```python
279
305
  from shiny_plotly import extend_traces, relayout, restyle
@@ -301,9 +327,15 @@ async def _highlight():
301
327
  - `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
328
  - `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
329
  - `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.
330
+ - `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.
331
+ - `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.
332
+ - `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.
333
+ - `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
304
334
 
305
335
  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
336
 
337
+ A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
338
+
307
339
  ### Lower level
308
340
 
309
341
  - `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`.
@@ -334,11 +366,19 @@ enable_compressed_plotly_js(app)
334
366
 
335
367
  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
368
 
369
+ ### Shinylive
370
+
371
+ 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, and its deployed copy is the [live demo](https://rvben.github.io/shiny-plotly/). The Pages workflow builds that demo from the current checkout's wheel (`make site`) and deploys only after a headless Chromium has watched both tabs render (`make site-check`).
372
+
337
373
  ## Examples
338
374
 
339
375
  ```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
376
+ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
377
+ uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
378
+ uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
379
+ uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
380
+ uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
381
+ uvx shinylive export examples/shinylive site # the same package, running in the browser
342
382
  ```
343
383
 
344
384
  ## 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,114 @@
1
+ """The live demo behind https://rvben.github.io/shiny-plotly/, running in the browser.
2
+
3
+ The whole app runs in the browser via pyodide: no server. Two tabs show the package's
4
+ two sides. Stream draws one empty figure and feeds it through ``extend_traces``, one
5
+ small message per tick, no re-render. Explore forwards plotly's click and box-select
6
+ events to Shiny inputs. Both figures follow the page's color mode via ``theme="auto"``
7
+ and the toggle in the corner.
8
+
9
+ Export this directory to a static site and serve it:
10
+
11
+ uvx shinylive export examples/shinylive site
12
+ python -m http.server 8000 -d site
13
+
14
+ That installs the released package from PyPI (requirements.txt); the deployed demo is
15
+ built by ``make site``, which installs the wheel from the current checkout instead.
16
+ """
17
+
18
+ import random
19
+ from datetime import datetime
20
+
21
+ import plotly.graph_objects as go
22
+ from shiny import App, Inputs, Outputs, Session, reactive, render, ui
23
+
24
+ from shiny_plotly import extend_traces, output_plotly, render_plotly
25
+
26
+ TICK_SECONDS = 0.5
27
+ CLUSTERS = {"alpha": (0.0, 0.0), "beta": (3.0, 2.0), "gamma": (1.0, 4.0)}
28
+
29
+ app_ui = ui.page_fillable(
30
+ ui.div(
31
+ ui.tags.b("shiny-plotly"),
32
+ ui.span("plotly figures in Shiny for Python, without shinywidgets", class_="text-muted"),
33
+ ui.div(ui.input_dark_mode(), class_="ms-auto"),
34
+ class_="d-flex align-items-center gap-3",
35
+ ),
36
+ ui.navset_card_underline(
37
+ ui.nav_panel(
38
+ "Stream",
39
+ ui.layout_columns(
40
+ ui.input_switch("run", "Stream", value=True),
41
+ ui.input_slider("window", "Window (points)", min=50, max=500, value=200, step=50),
42
+ col_widths=(3, 9),
43
+ ),
44
+ output_plotly("stream"),
45
+ ),
46
+ ui.nav_panel(
47
+ "Explore",
48
+ output_plotly("explore"),
49
+ ui.layout_columns(ui.output_text("clicked"), ui.output_text("picked")),
50
+ ),
51
+ ),
52
+ title="shiny-plotly demo",
53
+ )
54
+
55
+
56
+ def server(input: Inputs, output: Outputs, session: Session):
57
+ # Per-session: each visitor gets their own stream.
58
+ state = {"a": 20.0, "b": 50.0}
59
+
60
+ @render_plotly(theme="auto")
61
+ def stream():
62
+ fig = go.Figure(
63
+ [
64
+ go.Scatter(x=[], y=[], mode="lines", name="sensor a"),
65
+ go.Scatter(x=[], y=[], mode="lines", name="sensor b"),
66
+ ]
67
+ )
68
+ return fig.update_layout(uirevision="keep")
69
+
70
+ @reactive.effect
71
+ async def _tick():
72
+ reactive.invalidate_later(TICK_SECONDS)
73
+ if not input.run():
74
+ return
75
+ now = datetime.now()
76
+ state["a"] += random.uniform(-1.0, 1.0)
77
+ state["b"] += random.uniform(-2.0, 2.0)
78
+ # One column of new values per trace; max_points keeps the rolling window.
79
+ await extend_traces(
80
+ "stream",
81
+ {"x": [[now], [now]], "y": [[state["a"]], [state["b"]]]},
82
+ max_points=input.window(),
83
+ )
84
+
85
+ @render_plotly(theme="auto", events=("click", "selected"))
86
+ def explore():
87
+ rng = random.Random(7)
88
+ fig = go.Figure()
89
+ for name, (cx, cy) in CLUSTERS.items():
90
+ fig.add_scatter(
91
+ x=[cx + rng.gauss(0, 0.8) for _ in range(40)],
92
+ y=[cy + rng.gauss(0, 0.8) for _ in range(40)],
93
+ mode="markers",
94
+ name=name,
95
+ )
96
+ # Select mode from the start, so a plain drag is a box selection.
97
+ return fig.update_layout(dragmode="select")
98
+
99
+ @render.text
100
+ def clicked():
101
+ if not input.explore_click.is_set():
102
+ return "Click a point."
103
+ point = input.explore_click()["points"][0]
104
+ name = list(CLUSTERS)[point["curveNumber"]]
105
+ return f"Clicked {name} at ({point['x']:.2f}, {point['y']:.2f})."
106
+
107
+ @render.text
108
+ def picked():
109
+ if not input.explore_selected.is_set() or input.explore_selected() is None:
110
+ return "Drag a box to select points."
111
+ return f"{len(input.explore_selected()['points'])} points selected."
112
+
113
+
114
+ 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