shiny-plotly 0.3.1__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 (38) hide show
  1. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/.gitignore +1 -0
  2. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/CHANGELOG.md +8 -0
  3. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/PKG-INFO +15 -8
  4. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/README.md +14 -7
  5. shiny_plotly-0.3.2/examples/shinylive/app.py +114 -0
  6. shiny_plotly-0.3.2/examples/streaming_app.py +69 -0
  7. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/pyproject.toml +2 -2
  8. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_render.py +23 -7
  9. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/www/shiny-plotly.js +26 -0
  10. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/apps.py +24 -0
  11. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/conftest.py +15 -0
  12. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_events.py +53 -0
  13. shiny_plotly-0.3.2/tests/browser/test_shinylive_demo.py +48 -0
  14. shiny_plotly-0.3.2/tests/browser/test_streaming_example.py +39 -0
  15. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_examples.py +1 -0
  16. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_render_plotly.py +10 -1
  17. shiny_plotly-0.3.1/examples/shinylive/app.py +0 -33
  18. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/LICENSE +0 -0
  19. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/core_app.py +0 -0
  20. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/dark_app.py +0 -0
  21. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/events_app.py +0 -0
  22. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/express_app.py +0 -0
  23. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/shinylive/requirements.txt +0 -0
  24. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/__init__.py +0 -0
  25. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_deps.py +0 -0
  26. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_html.py +0 -0
  27. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_serve.py +0 -0
  28. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_update.py +0 -0
  29. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/py.typed +0 -0
  30. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/__init__.py +0 -0
  31. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_browser.py +0 -0
  32. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_dark_mode.py +0 -0
  33. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_update.py +0 -0
  34. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/newplot.py +0 -0
  35. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_compressed_js.py +0 -0
  36. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_fig_to_ui.py +0 -0
  37. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_plotly_js.py +0 -0
  38. {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_update.py +0 -0
@@ -7,3 +7,4 @@ __pycache__/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
9
  tmp/
10
+ site/
@@ -7,6 +7,14 @@ 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
+
10
18
  ## [0.3.1](https://github.com/rvben/shiny-plotly/compare/v0.3.0...v0.3.1) - 2026-08-20
11
19
 
12
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shiny-plotly
3
- Version: 0.3.1
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
@@ -38,6 +38,8 @@ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https:
38
38
  [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
39
39
  [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
40
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
+
41
43
  ```python
42
44
  from shiny_plotly import output_plotly, render_plotly
43
45
 
@@ -247,7 +249,7 @@ def sales():
247
249
 
248
250
  ### Events back to Shiny
249
251
 
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`.
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`.
251
253
 
252
254
  ```python
253
255
  @render_plotly(events=("click", "selected"))
@@ -270,6 +272,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
270
272
  | `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
271
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) |
272
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 |
273
278
 
274
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.
275
280
 
@@ -312,15 +317,14 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
312
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.
313
318
 
314
319
  ```python
315
- LEGEND_TO_INPUT = """
316
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
317
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
318
- return true; // let plotly toggle the trace as usual
320
+ ANNOTATION_TO_INPUT = """
321
+ document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
322
+ Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
319
323
  });
320
324
  """
321
325
 
322
326
 
323
- @render_plotly(post_script=LEGEND_TO_INPUT)
327
+ @render_plotly(post_script=ANNOTATION_TO_INPUT)
324
328
  def scatter(): ...
325
329
  ```
326
330
 
@@ -361,6 +365,8 @@ async def _highlight():
361
365
 
362
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.
363
367
 
368
+ A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
369
+
364
370
  ### Lower level
365
371
 
366
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`.
@@ -393,7 +399,7 @@ And if a reverse proxy in front of the app does its own compression and caching,
393
399
 
394
400
  ### Shinylive
395
401
 
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.
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`).
397
403
 
398
404
  ## Examples
399
405
 
@@ -402,6 +408,7 @@ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a
402
408
  uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
403
409
  uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
404
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
405
412
  uvx shinylive export examples/shinylive site # the same package, running in the browser
406
413
  ```
407
414
 
@@ -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
 
@@ -216,7 +218,7 @@ def sales():
216
218
 
217
219
  ### Events back to Shiny
218
220
 
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`.
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`.
220
222
 
221
223
  ```python
222
224
  @render_plotly(events=("click", "selected"))
@@ -239,6 +241,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
239
241
  | `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
240
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) |
241
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 |
242
247
 
243
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.
244
249
 
@@ -281,15 +286,14 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
281
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.
282
287
 
283
288
  ```python
284
- LEGEND_TO_INPUT = """
285
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
286
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
287
- 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'});
288
292
  });
289
293
  """
290
294
 
291
295
 
292
- @render_plotly(post_script=LEGEND_TO_INPUT)
296
+ @render_plotly(post_script=ANNOTATION_TO_INPUT)
293
297
  def scatter(): ...
294
298
  ```
295
299
 
@@ -330,6 +334,8 @@ async def _highlight():
330
334
 
331
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.
332
336
 
337
+ A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
338
+
333
339
  ### Lower level
334
340
 
335
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`.
@@ -362,7 +368,7 @@ And if a reverse proxy in front of the app does its own compression and caching,
362
368
 
363
369
  ### Shinylive
364
370
 
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.
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`).
366
372
 
367
373
  ## Examples
368
374
 
@@ -371,6 +377,7 @@ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a
371
377
  uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
372
378
  uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
373
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
374
381
  uvx shinylive export examples/shinylive site # the same package, running in the browser
375
382
  ```
376
383
 
@@ -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,69 @@
1
+ """Streaming example: a live rolling window fed by extend_traces, no re-render.
2
+
3
+ The figure is drawn once, empty; every point after that arrives through
4
+ ``extend_traces``, which appends in the browser and drops the oldest points past the
5
+ window. Nothing re-renders, so the stream costs one small message per tick and a zoom
6
+ or pan the user dragged stays where they put it. The switch pauses the stream; the
7
+ slider resizes the window from the next tick on.
8
+
9
+ Run with: uv run --with shiny-plotly shiny run examples/streaming_app.py
10
+ """
11
+
12
+ import random
13
+ from datetime import datetime
14
+
15
+ import plotly.graph_objects as go
16
+ from shiny import App, Inputs, Outputs, Session, reactive, ui
17
+
18
+ from shiny_plotly import extend_traces, output_plotly, render_plotly
19
+
20
+ TICK_SECONDS = 0.25
21
+
22
+ app_ui = ui.page_fillable(
23
+ ui.layout_columns(
24
+ ui.input_switch("run", "Stream", value=True),
25
+ ui.input_slider("window", "Window (points)", min=50, max=1000, value=300, step=50),
26
+ col_widths=(3, 9),
27
+ ),
28
+ ui.card(
29
+ ui.card_header("Two sensors, one point per tick"),
30
+ output_plotly("stream"),
31
+ full_screen=True,
32
+ ),
33
+ title="shiny-plotly streaming",
34
+ )
35
+
36
+
37
+ def server(input: Inputs, output: Outputs, session: Session):
38
+ # Per-session: each visitor gets their own stream.
39
+ state = {"a": 20.0, "b": 50.0}
40
+
41
+ @render_plotly(theme="auto")
42
+ def stream():
43
+ fig = go.Figure(
44
+ [
45
+ go.Scatter(x=[], y=[], mode="lines", name="sensor a"),
46
+ go.Scatter(x=[], y=[], mode="lines", name="sensor b"),
47
+ ]
48
+ )
49
+ return fig.update_layout(uirevision="keep")
50
+
51
+ @reactive.effect
52
+ async def _tick():
53
+ reactive.invalidate_later(TICK_SECONDS)
54
+ if not input.run():
55
+ return
56
+ now = datetime.now()
57
+ state["a"] += random.uniform(-1.0, 1.0)
58
+ state["b"] += random.uniform(-2.0, 2.0)
59
+ # One column of new values per trace; max_points keeps the rolling window. Sent
60
+ # while the first (empty) figure is still on its way, the update is held in the
61
+ # browser and applied right after the draw, so nothing is lost at startup.
62
+ await extend_traces(
63
+ "stream",
64
+ {"x": [[now], [now]], "y": [[state["a"]], [state["b"]]]},
65
+ max_points=input.window(),
66
+ )
67
+
68
+
69
+ 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.1"
7
+ version = "0.3.2"
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"
@@ -85,7 +85,7 @@ target-version = "py310"
85
85
  select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
86
86
 
87
87
  [tool.pyright]
88
- include = ["src", "tests", "examples", "bench"]
88
+ include = ["src", "tests", "examples", "bench", "tools"]
89
89
  pythonVersion = "3.10"
90
90
  typeCheckingMode = "standard"
91
91
 
@@ -17,7 +17,15 @@ from ._serve import enable_compressed_plotly_js
17
17
  __all__ = ("DEFAULT_MAX_EVENT_POINTS", "EVENTS", "output_plotly", "render_plotly")
18
18
 
19
19
  # Plotly events that can be forwarded to Shiny inputs, in the order they are sent.
20
- EVENTS = ("click", "hover", "selected", "relayout")
20
+ EVENTS = (
21
+ "click",
22
+ "doubleclick",
23
+ "hover",
24
+ "selected",
25
+ "relayout",
26
+ "legendclick",
27
+ "legenddoubleclick",
28
+ )
21
29
 
22
30
  # Points per event above which the browser sends the count and the selection's geometry
23
31
  # instead of the points. Each point is about 100 bytes of JSON; at this cap an event is
@@ -170,18 +178,26 @@ class render_plotly(Renderer[Figure]):
170
178
  ``{plot_id}`` replaced by the graph div's id. For anything the ``events`` option
171
179
  does not cover; handlers attached here stay attached across re-renders.
172
180
  events
173
- Plotly events to forward to Shiny inputs: any of ``"click"``, ``"hover"``,
174
- ``"selected"`` and ``"relayout"`` (one name or an iterable of names). Each arrives
175
- as ``input.<id>_<event>``, namespaced like the output inside a module.
181
+ Plotly events to forward to Shiny inputs: any of ``"click"``, ``"doubleclick"``,
182
+ ``"hover"``, ``"selected"``, ``"relayout"``, ``"legendclick"`` and
183
+ ``"legenddoubleclick"`` (one name or an iterable of names). Each arrives as
184
+ ``input.<id>_<event>``, namespaced like the output inside a module.
176
185
  ``click``, ``hover`` and ``selected`` carry ``{"points": [...]}`` where each point
177
186
  holds plotly's scalar fields (``curveNumber``, ``pointNumber``, ``pointIndex``,
178
187
  ``x``, ``y``, ``z``, ``text``, ``label``, ``value``, ...) plus ``customdata``,
179
188
  ``bbox`` and ``pointNumbers`` when present; a box or lasso selection adds ``range``
180
189
  or ``lassoPoints``. ``relayout`` carries plotly's relayout data as is (zoom and pan
181
190
  ranges, ``autorange``, ``dragmode``; a resize reports ``{"autosize": true}``).
182
- A click fires on every click, repeated or not; hover is debounced and becomes
183
- ``None`` once the pointer leaves the graph; a double-click deselect sets
184
- ``selected`` to ``None``.
191
+ ``doubleclick`` (the double-click on the plot area that resets the axes) carries a
192
+ running count of double-clicks, since plotly hands it no data; the change is what
193
+ invalidates the input. ``legendclick`` and ``legenddoubleclick`` carry
194
+ ``{"curve_number", "expanded_index", "name", "visible"}`` for the trace whose
195
+ legend item was clicked, with ``visible`` as it stood before the click's toggle
196
+ (``True`` or ``"legendonly"``), plus ``label`` for trace types whose legend items
197
+ are labels (pie, funnelarea); the default toggle still happens.
198
+ A click fires on every click, repeated or not, and so do the legend events; hover
199
+ is debounced and becomes ``None`` once the pointer leaves the graph; a
200
+ double-click deselect sets ``selected`` to ``None``.
185
201
  max_event_points
186
202
  The most points one event carries, 10 000 by default. An event with more points
187
203
  (a box or lasso over a dense trace) arrives with ``"points": None`` and
@@ -162,6 +162,20 @@
162
162
  return out;
163
163
  }
164
164
 
165
+ // A legend event names a trace, not points: the trace's index and, because the event
166
+ // fires before the default toggle, the visibility the click is about to change. For a
167
+ // trace type whose legend items are labels (pie, funnelarea) plotly adds the label.
168
+ function legendData(gd, ev) {
169
+ var out = { curve_number: ev.curveNumber, expanded_index: ev.expandedIndex };
170
+ var trace = gd._fullData && gd._fullData[ev.curveNumber];
171
+ if (trace) {
172
+ out.name = trace.name;
173
+ out.visible = trace.visible;
174
+ }
175
+ if (ev.label !== undefined) out.label = ev.label;
176
+ return out;
177
+ }
178
+
165
179
  // Wires the requested plotly events of one graph div to inputs named <output id>_<event>.
166
180
  // Called once per graph div, right after its first draw; Plotly.react keeps the handlers.
167
181
  function attachEvents(gd, outputId, names, maxPoints) {
@@ -189,6 +203,18 @@
189
203
  gd.on("plotly_deselect", function () { send("selected", null); });
190
204
  } else if (name === "relayout") {
191
205
  gd.on("plotly_relayout", function (ev) { send("relayout", relayoutData(ev)); });
206
+ } else if (name === "doubleclick") {
207
+ // The event hands its handler nothing, so the value is a count kept on the graph
208
+ // div: it changes on every double-click, which is what invalidates the input.
209
+ gd.on("plotly_doubleclick", function () {
210
+ gd._shinyPlotlyDblclicks = (gd._shinyPlotlyDblclicks || 0) + 1;
211
+ send("doubleclick", gd._shinyPlotlyDblclicks);
212
+ });
213
+ } else if (name === "legendclick" || name === "legenddoubleclick") {
214
+ // priority event, like click: a repeated click on the same item counts again.
215
+ gd.on("plotly_" + name, function (ev) {
216
+ send(name, legendData(gd, ev), { priority: "event" });
217
+ });
192
218
  }
193
219
  });
194
220
  }
@@ -127,12 +127,16 @@ def make_events_app() -> App:
127
127
  output_plotly("fig", height="300px", width="500px"),
128
128
  output_plotly("sel", height="300px", width="500px"),
129
129
  output_plotly("dense", height="300px", width="500px"),
130
+ output_plotly("leg", height="300px", width="500px"),
130
131
  ui.output_text("click_out"),
131
132
  ui.output_text("click_count"),
132
133
  ui.output_text("hover_out"),
133
134
  ui.output_text("relayout_out"),
134
135
  ui.output_text("selected_out"),
135
136
  ui.output_text("dense_out"),
137
+ ui.output_text("legendclick_out"),
138
+ ui.output_text("legenddbl_out"),
139
+ ui.output_text("dbl_out"),
136
140
  events_mod_ui("m"),
137
141
  )
138
142
 
@@ -156,6 +160,12 @@ def make_events_app() -> App:
156
160
  fig = go.Figure(go.Scatter(x=list(range(8)), y=[1] * 8, mode="markers"))
157
161
  return fig.update_layout(dragmode="select")
158
162
 
163
+ @render_plotly(events=("doubleclick", "legendclick", "legenddoubleclick"))
164
+ def leg():
165
+ return go.Figure(
166
+ [go.Scatter(y=[1, 2], name="alpha"), go.Scatter(y=[2, 1], name="beta")]
167
+ )
168
+
159
169
  @reactive.effect
160
170
  @reactive.event(input.fig_click)
161
171
  def _count():
@@ -185,6 +195,20 @@ def make_events_app() -> App:
185
195
  def dense_out():
186
196
  return as_text(input.dense_selected()) if input.dense_selected.is_set() else "-"
187
197
 
198
+ @render.text
199
+ def legendclick_out():
200
+ return as_text(input.leg_legendclick()) if input.leg_legendclick.is_set() else "-"
201
+
202
+ @render.text
203
+ def legenddbl_out():
204
+ if not input.leg_legenddoubleclick.is_set():
205
+ return "-"
206
+ return as_text(input.leg_legenddoubleclick())
207
+
208
+ @render.text
209
+ def dbl_out():
210
+ return as_text(input.leg_doubleclick()) if input.leg_doubleclick.is_set() else "-"
211
+
188
212
  events_mod_server("m")
189
213
 
190
214
  return App(app_ui, server)
@@ -1,12 +1,15 @@
1
1
  """Run the browser test apps on a real uvicorn server for the playwright tests."""
2
2
 
3
+ import importlib.util
3
4
  import threading
4
5
  import time
5
6
  from collections.abc import Iterator
7
+ from pathlib import Path
6
8
 
7
9
  import pytest
8
10
  import uvicorn
9
11
  from playwright.sync_api import Page
12
+ from shiny import App
10
13
  from starlette.applications import Starlette
11
14
  from starlette.routing import Mount
12
15
 
@@ -20,6 +23,16 @@ from .apps import (
20
23
  )
21
24
 
22
25
 
26
+ def load_example(name: str) -> App:
27
+ """The ``app`` of an example file, imported as is: the tests drive the real example."""
28
+ path = Path(__file__).parent.parent.parent / "examples" / name
29
+ spec = importlib.util.spec_from_file_location(path.stem, path)
30
+ assert spec is not None and spec.loader is not None
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module)
33
+ return module.app
34
+
35
+
23
36
  @pytest.fixture(scope="session")
24
37
  def server_url() -> Iterator[str]:
25
38
  root = Starlette(
@@ -29,6 +42,8 @@ def server_url() -> Iterator[str]:
29
42
  Mount("/live", app=make_live_app()),
30
43
  Mount("/dark", app=make_dark_app()),
31
44
  Mount("/theme", app=make_theme_app()),
45
+ Mount("/stream", app=load_example("streaming_app.py")),
46
+ Mount("/demo", app=load_example("shinylive/app.py")),
32
47
  Mount("/", app=make_app()),
33
48
  ]
34
49
  )
@@ -16,6 +16,7 @@ def app(page: Page, server_url: str, errors: list[str]) -> Iterator[Page]:
16
16
  page.goto(server_url + "/events/")
17
17
  expect(page.locator(f"#fig {SVG}").first).to_be_visible()
18
18
  expect(page.locator(f"#sel {SVG}").first).to_be_visible()
19
+ expect(page.locator(f"#leg {SVG}").first).to_be_visible()
19
20
  expect(page.locator(f"#m-fig {SVG}").first).to_be_visible()
20
21
  expect(page.locator("#click_out")).to_have_text("-")
21
22
  yield page
@@ -152,6 +153,58 @@ def test_a_selection_above_max_event_points_arrives_as_count_and_range_without_p
152
153
  assert "point_count" not in event
153
154
 
154
155
 
156
+ def legend_item(page: Page, output_id: str, index: int) -> Locator:
157
+ return page.locator(f"#{output_id} .legend .traces").nth(index)
158
+
159
+
160
+ def plot_dblclick(page: Page, output_id: str) -> None:
161
+ drag_area = page.locator(f"#{output_id} .nsewdrag")
162
+ drag_area.scroll_into_view_if_needed() # mouse coordinates are viewport coordinates
163
+ box = drag_area.bounding_box()
164
+ assert box is not None
165
+ page.mouse.dblclick(box["x"] + box["width"] * 0.5, box["y"] + box["height"] * 0.5)
166
+
167
+
168
+ def test_a_legend_click_reports_the_trace_and_its_pre_toggle_visibility(app: Page):
169
+ legend_item(app, "leg", 1).click(force=True)
170
+
171
+ wait_for_change(app, "legendclick_out", "-")
172
+ first = received(app, "legendclick_out")
173
+ assert first == {"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}
174
+
175
+ # Past plotly's double-click window, so the second click is a single click again, on a
176
+ # trace the first click has hidden by now.
177
+ app.wait_for_timeout(400)
178
+ previous = app.locator("#legendclick_out").inner_text()
179
+ legend_item(app, "leg", 1).click(force=True)
180
+
181
+ wait_for_change(app, "legendclick_out", previous)
182
+ second = received(app, "legendclick_out")
183
+ assert isinstance(second, dict)
184
+ assert second["visible"] == "legendonly", "the state before this click's toggle"
185
+
186
+
187
+ def test_a_legend_double_click_arrives_with_the_trace(app: Page):
188
+ legend_item(app, "leg", 0).dblclick(force=True)
189
+
190
+ wait_for_change(app, "legenddbl_out", "-")
191
+ event = received(app, "legenddbl_out")
192
+ assert isinstance(event, dict)
193
+ assert event["curve_number"] == 0
194
+ assert event["name"] == "alpha"
195
+
196
+
197
+ def test_a_double_click_on_the_plot_arrives_as_a_running_count(app: Page):
198
+ plot_dblclick(app, "leg")
199
+
200
+ expect(app.locator("#dbl_out")).to_have_text("1")
201
+
202
+ app.wait_for_timeout(400)
203
+ plot_dblclick(app, "leg")
204
+
205
+ expect(app.locator("#dbl_out")).to_have_text("2")
206
+
207
+
155
208
  def test_inputs_are_namespaced_inside_a_module(app: Page):
156
209
  bar(app, "m-fig", 2).click(force=True)
157
210
 
@@ -0,0 +1,48 @@
1
+ """The shinylive demo app, served as is: what ships to Pages works before it ships."""
2
+
3
+ from collections.abc import Iterator
4
+
5
+ import pytest
6
+ from playwright.sync_api import Page, expect
7
+
8
+ pytestmark = pytest.mark.browser
9
+
10
+ GD = "document.querySelector('#stream .plotly-graph-div')"
11
+
12
+
13
+ @pytest.fixture
14
+ def app(page: Page, server_url: str, errors: list[str]) -> Iterator[Page]:
15
+ page.goto(server_url + "/demo/")
16
+ expect(page.locator("#stream svg.main-svg").first).to_be_visible()
17
+ yield page
18
+ assert errors == []
19
+
20
+
21
+ def test_the_stream_tab_accumulates_points(app: Page):
22
+ app.wait_for_function(
23
+ f"() => {{ const gd = {GD}; return gd && gd.data && gd.data[0].x.length >= 3; }}"
24
+ )
25
+
26
+ assert app.evaluate(f"{GD}.data[1].x.length") >= 3, "both traces stream"
27
+
28
+
29
+ def test_the_explore_tab_reports_a_click_and_a_selection(app: Page):
30
+ app.get_by_role("tab", name="Explore").click()
31
+ expect(app.locator("#explore svg.main-svg").first).to_be_visible()
32
+ expect(app.locator("#clicked")).to_have_text("Click a point.")
33
+
34
+ app.locator("#explore .scatterlayer .point").first.click(force=True)
35
+
36
+ expect(app.locator("#clicked")).not_to_have_text("Click a point.")
37
+
38
+ # The figure starts in select mode, so a plain drag is a box selection.
39
+ drag_area = app.locator("#explore .nsewdrag")
40
+ drag_area.scroll_into_view_if_needed()
41
+ box = drag_area.bounding_box()
42
+ assert box is not None
43
+ app.mouse.move(box["x"] + box["width"] * 0.1, box["y"] + box["height"] * 0.1)
44
+ app.mouse.down()
45
+ app.mouse.move(box["x"] + box["width"] * 0.9, box["y"] + box["height"] * 0.9, steps=8)
46
+ app.mouse.up()
47
+
48
+ expect(app.locator("#picked")).to_contain_text("points selected")
@@ -0,0 +1,39 @@
1
+ """The streaming example, served as is: points flow in without a re-render and pause."""
2
+
3
+ from collections.abc import Iterator
4
+
5
+ import pytest
6
+ from playwright.sync_api import Page, expect
7
+
8
+ pytestmark = pytest.mark.browser
9
+
10
+ GD = "document.querySelector('#stream .plotly-graph-div')"
11
+
12
+
13
+ @pytest.fixture
14
+ def app(page: Page, server_url: str, errors: list[str]) -> Iterator[Page]:
15
+ page.goto(server_url + "/stream/")
16
+ expect(page.locator("#stream svg.main-svg").first).to_be_visible()
17
+ yield page
18
+ assert errors == []
19
+
20
+
21
+ def trace_len(page: Page) -> int:
22
+ return page.evaluate(f"{GD}.data[0].x.length")
23
+
24
+
25
+ def test_points_accumulate_through_extend_traces_and_the_switch_pauses(app: Page):
26
+ # Ticks arrive every 250 ms; the first ones were sent before the empty figure was
27
+ # drawn and must have been held and applied, not lost.
28
+ app.wait_for_function(
29
+ f"() => {{ const gd = {GD}; return gd && gd.data && gd.data[0].x.length >= 3; }}"
30
+ )
31
+ assert app.evaluate(f"{GD}.data[1].x.length") >= 3, "both traces stream"
32
+
33
+ app.locator("#run").click() # the switch: pause
34
+
35
+ app.wait_for_timeout(300) # a tick already in flight may still land
36
+ paused_at = trace_len(app)
37
+ app.wait_for_timeout(900)
38
+
39
+ assert trace_len(app) == paused_at, "no ticks after the pause settled"
@@ -13,6 +13,7 @@ CORE_EXAMPLES = (
13
13
  "core_app.py",
14
14
  "events_app.py",
15
15
  "dark_app.py",
16
+ "streaming_app.py",
16
17
  "shinylive/app.py",
17
18
  )
18
19
 
@@ -95,13 +95,22 @@ def test_a_single_event_name_is_accepted_as_a_string():
95
95
 
96
96
 
97
97
  def test_unknown_event_names_fail_at_decoration_time():
98
- with pytest.raises(ValueError, match=r"plotly_clack.*click, hover, selected, relayout"):
98
+ expected = "click, doubleclick, hover, selected, relayout, legendclick, legenddoubleclick"
99
+ with pytest.raises(ValueError, match=rf"plotly_clack.*{expected}"):
99
100
 
100
101
  @render_plotly(events=("click", "plotly_clack"))
101
102
  def sales():
102
103
  return bar()
103
104
 
104
105
 
106
+ def test_the_double_click_and_legend_events_are_accepted():
107
+ @render_plotly(events=("legenddoubleclick", "doubleclick", "legendclick"))
108
+ def sales():
109
+ return bar()
110
+
111
+ assert sales.events == ("doubleclick", "legendclick", "legenddoubleclick")
112
+
113
+
105
114
  def test_max_event_points_defaults_to_ten_thousand_and_is_recorded():
106
115
  @render_plotly
107
116
  def sales():
@@ -1,33 +0,0 @@
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)
File without changes