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.
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/.gitignore +1 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/CHANGELOG.md +8 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/PKG-INFO +15 -8
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/README.md +14 -7
- shiny_plotly-0.3.2/examples/shinylive/app.py +114 -0
- shiny_plotly-0.3.2/examples/streaming_app.py +69 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/pyproject.toml +2 -2
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_render.py +23 -7
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/www/shiny-plotly.js +26 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/apps.py +24 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/conftest.py +15 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_events.py +53 -0
- shiny_plotly-0.3.2/tests/browser/test_shinylive_demo.py +48 -0
- shiny_plotly-0.3.2/tests/browser/test_streaming_example.py +39 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_examples.py +1 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_render_plotly.py +10 -1
- shiny_plotly-0.3.1/examples/shinylive/app.py +0 -33
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/LICENSE +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/core_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/__init__.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_serve.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/_update.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_browser.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_dark_mode.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/browser/test_update.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_compressed_js.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_plotly_js.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.2}/tests/test_update.py +0 -0
|
@@ -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.
|
|
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
|
[](https://pypi.org/project/shiny-plotly/)
|
|
39
39
|
[](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.
|
|
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
|
-
|
|
316
|
-
document.getElementById('{plot_id}').on('
|
|
317
|
-
Shiny.setInputValue('
|
|
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=
|
|
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
|
[](https://pypi.org/project/shiny-plotly/)
|
|
8
8
|
[](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.
|
|
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
|
-
|
|
285
|
-
document.getElementById('{plot_id}').on('
|
|
286
|
-
Shiny.setInputValue('
|
|
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=
|
|
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.
|
|
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 = (
|
|
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"``, ``"
|
|
174
|
-
``"selected"
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
``
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|