shiny-plotly 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/.gitignore +1 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/CHANGELOG.md +20 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/PKG-INFO +55 -14
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/README.md +53 -13
- shiny_plotly-0.3.2/examples/dark_app.py +53 -0
- shiny_plotly-0.3.2/examples/events_app.py +64 -0
- shiny_plotly-0.3.2/examples/shinylive/app.py +114 -0
- shiny_plotly-0.3.2/examples/shinylive/requirements.txt +3 -0
- shiny_plotly-0.3.2/examples/streaming_app.py +69 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/pyproject.toml +9 -2
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/__init__.py +13 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_render.py +108 -7
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_serve.py +5 -2
- shiny_plotly-0.3.2/src/shiny_plotly/_update.py +220 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/www/shiny-plotly.js +101 -5
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/apps.py +78 -1
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/conftest.py +24 -1
- shiny_plotly-0.3.2/tests/browser/test_dark_mode.py +99 -0
- {shiny_plotly-0.3.0 → 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.0 → shiny_plotly-0.3.2}/tests/browser/test_update.py +27 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_compressed_js.py +26 -0
- shiny_plotly-0.3.2/tests/test_examples.py +41 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_render_plotly.py +122 -2
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_update.py +75 -5
- shiny_plotly-0.3.0/src/shiny_plotly/_update.py +0 -107
- shiny_plotly-0.3.0/tests/browser/test_dark_mode.py +0 -41
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/LICENSE +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/examples/core_app.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/browser/test_browser.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.3.0 → shiny_plotly-0.3.2}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Three more forwardable events: `doubleclick` arrives as a running count of plot-area double-clicks (plotly hands the event no data; the count's change invalidates the input); `legendclick` and `legenddoubleclick` arrive as the clicked trace's `curve_number`, `expanded_index`, `name` and pre-toggle `visible`, plus `label` for trace types whose legend items are labels. The default toggle and isolate behaviors still happen.
|
|
15
|
+
- `examples/streaming_app.py`: a live rolling window fed by `extend_traces`, with a pause switch and a window slider; a browser test drives the example file itself and proves the stream flows and pauses.
|
|
16
|
+
- A live demo at https://rvben.github.io/shiny-plotly/: `examples/shinylive/app.py` grew into a two-tab showcase (a streaming figure, click and box-select events, a dark mode toggle), deployed to GitHub Pages by a workflow that builds the site from the checkout's own wheel (`make site`) and gates the deploy on a headless Chromium watching both tabs render (`make site-check`). The committed example still installs from PyPI, so `uvx shinylive export examples/shinylive site` keeps working as documented.
|
|
17
|
+
|
|
18
|
+
## [0.3.1](https://github.com/rvben/shiny-plotly/compare/v0.3.0...v0.3.1) - 2026-08-20
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `@render_plotly(theme="auto")`: the figure follows the page's color mode in the browser, with no server round-trip. `"auto"` pairs plotly's `"plotly"` and `"plotly_dark"` templates; a `(light, dark)` tuple picks others, each a registered name, a `Template` object or a template dict. Both templates travel with the figure with transparent backgrounds so the page shows through; the browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when `data-bs-theme` (what `ui.input_dark_mode()` sets) or the OS `prefers-color-scheme` flips. Unknown template names raise at decoration time.
|
|
23
|
+
- The in-place update family is complete: `add_traces`, `delete_traces`, `prepend_traces` and `update` (a restyle and a relayout in one redraw) join `extend_traces`, `restyle` and `relayout`. Same contract as the first three: values through plotly's encoder, ids namespaced inside a module, updates held and applied in order while the output has no figure drawn.
|
|
24
|
+
- Examples for dark mode (`examples/dark_app.py`), dense-trace selections (`examples/events_app.py`) and a ready-to-export shinylive app (`examples/shinylive/`); a test keeps every example building.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Shinylive: under pyodide the compressed plotly.js route is skipped instead of crashing the session. Pyodide cannot start threads, so the compression thread raised `RuntimeError: can't start new thread` from the first `@render_plotly` of every session and no figure ever rendered; there is also nothing to serve there, as the browser loads assets from the shinylive bundle. Verified against a real `shinylive export`: 0.3.0 crashes, this version renders.
|
|
29
|
+
|
|
10
30
|
## [0.3.0](https://github.com/rvben/shiny-plotly/compare/v0.2.0...v0.3.0) - 2026-08-20
|
|
11
31
|
|
|
12
32
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rvben/shiny-plotly
|
|
6
6
|
Project-URL: Repository, https://github.com/rvben/shiny-plotly
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
22
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
23
|
Classifier: Typing :: Typed
|
|
23
24
|
Requires-Python: >=3.10
|
|
@@ -37,6 +38,8 @@ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https:
|
|
|
37
38
|
[](https://pypi.org/project/shiny-plotly/)
|
|
38
39
|
[](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
|
|
39
40
|
|
|
41
|
+
**[Try the live demo](https://rvben.github.io/shiny-plotly/)**: a streaming figure and forwarded plotly events, running entirely in your browser through shinylive.
|
|
42
|
+
|
|
40
43
|
```python
|
|
41
44
|
from shiny_plotly import output_plotly, render_plotly
|
|
42
45
|
|
|
@@ -83,7 +86,7 @@ uv add shiny-plotly
|
|
|
83
86
|
pip install shiny-plotly
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.
|
|
89
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
|
|
87
90
|
|
|
88
91
|
## Use
|
|
89
92
|
|
|
@@ -158,6 +161,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
158
161
|
config={"displaylogo": False},
|
|
159
162
|
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
160
163
|
max_event_points=10_000, # above it an event carries the count and range, not the points
|
|
164
|
+
theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
|
|
161
165
|
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
162
166
|
)
|
|
163
167
|
def sales(): ...
|
|
@@ -204,7 +208,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
204
208
|
|
|
205
209
|
### Dark mode
|
|
206
210
|
|
|
207
|
-
Plotly does not follow Bootstrap's color mode by itself.
|
|
211
|
+
Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
app_ui = ui.page_fillable(
|
|
215
|
+
ui.input_dark_mode(),
|
|
216
|
+
ui.card(output_plotly("sales")),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@render_plotly(theme="auto")
|
|
221
|
+
def sales():
|
|
222
|
+
return px.bar(df, x="month", y="total")
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`"auto"` pairs plotly's own templates: `"plotly"` in light mode, `"plotly_dark"` in dark. A `(light, dark)` tuple picks different ones, each a registered name, a plotly `Template` object or a template dict:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
@render_plotly(theme=("seaborn", "plotly_dark"))
|
|
229
|
+
def sales(): ...
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
How it works: both templates travel with the figure, their `paper_bgcolor` and `plot_bgcolor` made transparent so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. The mode is `data-bs-theme` on `<html>`, which is what `ui.input_dark_mode()` maintains, or the OS `prefers-color-scheme` on a page without that attribute. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
|
|
233
|
+
|
|
234
|
+
The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
|
|
208
235
|
|
|
209
236
|
```python
|
|
210
237
|
app_ui = ui.page_fillable(
|
|
@@ -220,11 +247,9 @@ def sales():
|
|
|
220
247
|
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
221
248
|
```
|
|
222
249
|
|
|
223
|
-
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
224
|
-
|
|
225
250
|
### Events back to Shiny
|
|
226
251
|
|
|
227
|
-
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module.
|
|
252
|
+
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module. Seven are available: `click`, `doubleclick`, `hover`, `selected`, `relayout`, `legendclick` and `legenddoubleclick`.
|
|
228
253
|
|
|
229
254
|
```python
|
|
230
255
|
@render_plotly(events=("click", "selected"))
|
|
@@ -247,6 +272,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
247
272
|
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
248
273
|
| `selected` | `{"points": [...], "range": {"x": [..], "y": [..]}}` for a box, `lassoPoints` for a lasso; `None` after a double-click deselect; above `max_event_points` the points give way to `point_count` (below) |
|
|
249
274
|
| `relayout` | plotly's relayout data as is: `{"xaxis.range[0]": ..., "xaxis.range[1]": ...}` after a zoom or pan, `{"xaxis.autorange": True, ...}` after a reset, `{"dragmode": "pan"}` from the mode bar, `{"autosize": True}` after a resize |
|
|
275
|
+
| `doubleclick` | a running count of double-clicks on the plot area (the gesture that resets the axes); plotly hands the event no data, and the count's change is what invalidates the input |
|
|
276
|
+
| `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
|
|
277
|
+
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
250
278
|
|
|
251
279
|
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
252
280
|
|
|
@@ -289,21 +317,20 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
289
317
|
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
290
318
|
|
|
291
319
|
```python
|
|
292
|
-
|
|
293
|
-
document.getElementById('{plot_id}').on('
|
|
294
|
-
Shiny.setInputValue('
|
|
295
|
-
return true; // let plotly toggle the trace as usual
|
|
320
|
+
ANNOTATION_TO_INPUT = """
|
|
321
|
+
document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
|
|
322
|
+
Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
|
|
296
323
|
});
|
|
297
324
|
"""
|
|
298
325
|
|
|
299
326
|
|
|
300
|
-
@render_plotly(post_script=
|
|
327
|
+
@render_plotly(post_script=ANNOTATION_TO_INPUT)
|
|
301
328
|
def scatter(): ...
|
|
302
329
|
```
|
|
303
330
|
|
|
304
331
|
### Live updates without a re-render
|
|
305
332
|
|
|
306
|
-
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `restyle` and `
|
|
333
|
+
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `prepend_traces`, `add_traces`, `delete_traces`, `restyle`, `relayout` and `update` call the plotly.js functions of the same names on the graph an output holds. All of them are coroutines, so the effect that calls them is `async def`.
|
|
307
334
|
|
|
308
335
|
```python
|
|
309
336
|
from shiny_plotly import extend_traces, relayout, restyle
|
|
@@ -331,9 +358,15 @@ async def _highlight():
|
|
|
331
358
|
- `extend_traces(id, data, indices=None, *, max_points=None)`: `data` maps an array attribute to one sequence of new values per trace, in the order of `indices` (`{"x": [[t]], "y": [[v]]}` appends one point to one trace; `{"y": [[1], [2]]}` with `indices=[0, 1]` one point to each of two). `indices` (an int or a list) defaults to every trace; `max_points` drops the oldest points past that many, for a rolling window.
|
|
332
359
|
- `restyle(id, update, indices=None)`: `update` maps attribute paths to values; `{"marker.color": "red"}` applies to every trace in `indices`, a list value applies per trace (`{"opacity": [0.5, 1]}` with `indices=[0, 1]`).
|
|
333
360
|
- `relayout(id, update)`: layout attribute paths, `{"title.text": "Live"}`, `{"xaxis.range": [0, 10]}`, `{"xaxis.autorange": True}`. With `events="relayout"` on the output, the result comes back as `input.<id>_relayout`, the same as a user's zoom.
|
|
361
|
+
- `update(id, restyle=None, relayout=None, indices=None)`: a restyle and a relayout in one redraw, where separate calls would draw twice. At least one of the two is required; `indices` scopes the trace part.
|
|
362
|
+
- `prepend_traces(id, data, indices=None, *, max_points=None)`: the mirror of `extend_traces`; the new values go in front, and `max_points` drops points from the far end.
|
|
363
|
+
- `add_traces(id, traces, indices=None)`: whole new traces, each a graph object (`go.Scatter(y=[1, 2])`) or a dict (`{"y": [1], "type": "bar"}`); `indices` says where they land in the trace order, appended without it.
|
|
364
|
+
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
334
365
|
|
|
335
366
|
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
336
367
|
|
|
368
|
+
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
369
|
+
|
|
337
370
|
### Lower level
|
|
338
371
|
|
|
339
372
|
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns a `TagList` holding the plotly.js dependency, the helper dependency and a `<div class="shiny-plotly">` that draws the figure with `Plotly.newPlot` (plotly's own `to_html` fragment). Use it from a plain `@render.ui` that composes a figure with other UI, or from any htmltools context. Each render draws a fresh graph; an output that is only a figure is better served by `render_plotly`.
|
|
@@ -364,11 +397,19 @@ enable_compressed_plotly_js(app)
|
|
|
364
397
|
|
|
365
398
|
And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment; `enable_compressed_plotly_js` then returns `False` and adds nothing.
|
|
366
399
|
|
|
400
|
+
### Shinylive
|
|
401
|
+
|
|
402
|
+
Apps using `shiny-plotly` run under [Shinylive](https://shiny.posit.co/py/get-started/shinylive.html) (pyodide in the browser) as well; list `shiny-plotly` in the app's `requirements.txt` next to `plotly`. There is no HTTP server in the browser and pyodide cannot start threads, so the compression route above stands down under pyodide (`enable_compressed_plotly_js` returns `False`); everything else, rendering, events, in-place updates and themes, is browser-side already. Verified against a real `shinylive export`; `examples/shinylive/` is a ready-to-export app, and its deployed copy is the [live demo](https://rvben.github.io/shiny-plotly/). The Pages workflow builds that demo from the current checkout's wheel (`make site`) and deploys only after a headless Chromium has watched both tabs render (`make site-check`).
|
|
403
|
+
|
|
367
404
|
## Examples
|
|
368
405
|
|
|
369
406
|
```sh
|
|
370
|
-
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
371
|
-
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
407
|
+
uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
|
|
408
|
+
uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
|
|
409
|
+
uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
|
|
410
|
+
uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
|
|
411
|
+
uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
|
|
412
|
+
uvx shinylive export examples/shinylive site # the same package, running in the browser
|
|
372
413
|
```
|
|
373
414
|
|
|
374
415
|
## Development
|
|
@@ -7,6 +7,8 @@ Render [plotly](https://plotly.com/python/) figures in [Shiny for Python](https:
|
|
|
7
7
|
[](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
|
|
|
@@ -53,7 +55,7 @@ uv add shiny-plotly
|
|
|
53
55
|
pip install shiny-plotly
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.
|
|
58
|
+
Requires Python 3.10+, `shiny>=1.0`, `plotly>=5.5`.
|
|
57
59
|
|
|
58
60
|
## Use
|
|
59
61
|
|
|
@@ -128,6 +130,7 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
128
130
|
config={"displaylogo": False},
|
|
129
131
|
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
130
132
|
max_event_points=10_000, # above it an event carries the count and range, not the points
|
|
133
|
+
theme="auto", # follow the page's color mode in the browser; also takes (light, dark)
|
|
131
134
|
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
132
135
|
)
|
|
133
136
|
def sales(): ...
|
|
@@ -174,7 +177,30 @@ Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a s
|
|
|
174
177
|
|
|
175
178
|
### Dark mode
|
|
176
179
|
|
|
177
|
-
Plotly does not follow Bootstrap's color mode by itself.
|
|
180
|
+
Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
app_ui = ui.page_fillable(
|
|
184
|
+
ui.input_dark_mode(),
|
|
185
|
+
ui.card(output_plotly("sales")),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@render_plotly(theme="auto")
|
|
190
|
+
def sales():
|
|
191
|
+
return px.bar(df, x="month", y="total")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`"auto"` pairs plotly's own templates: `"plotly"` in light mode, `"plotly_dark"` in dark. A `(light, dark)` tuple picks different ones, each a registered name, a plotly `Template` object or a template dict:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
@render_plotly(theme=("seaborn", "plotly_dark"))
|
|
198
|
+
def sales(): ...
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
How it works: both templates travel with the figure, their `paper_bgcolor` and `plot_bgcolor` made transparent so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. The mode is `data-bs-theme` on `<html>`, which is what `ui.input_dark_mode()` maintains, or the OS `prefers-color-scheme` on a page without that attribute. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
|
|
202
|
+
|
|
203
|
+
The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
|
|
178
204
|
|
|
179
205
|
```python
|
|
180
206
|
app_ui = ui.page_fillable(
|
|
@@ -190,11 +216,9 @@ def sales():
|
|
|
190
216
|
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
191
217
|
```
|
|
192
218
|
|
|
193
|
-
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
194
|
-
|
|
195
219
|
### Events back to Shiny
|
|
196
220
|
|
|
197
|
-
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module.
|
|
221
|
+
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module. Seven are available: `click`, `doubleclick`, `hover`, `selected`, `relayout`, `legendclick` and `legenddoubleclick`.
|
|
198
222
|
|
|
199
223
|
```python
|
|
200
224
|
@render_plotly(events=("click", "selected"))
|
|
@@ -217,6 +241,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
217
241
|
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
218
242
|
| `selected` | `{"points": [...], "range": {"x": [..], "y": [..]}}` for a box, `lassoPoints` for a lasso; `None` after a double-click deselect; above `max_event_points` the points give way to `point_count` (below) |
|
|
219
243
|
| `relayout` | plotly's relayout data as is: `{"xaxis.range[0]": ..., "xaxis.range[1]": ...}` after a zoom or pan, `{"xaxis.autorange": True, ...}` after a reset, `{"dragmode": "pan"}` from the mode bar, `{"autosize": True}` after a resize |
|
|
244
|
+
| `doubleclick` | a running count of double-clicks on the plot area (the gesture that resets the axes); plotly hands the event no data, and the count's change is what invalidates the input |
|
|
245
|
+
| `legendclick` | `{"curve_number": 1, "expanded_index": 1, "name": "beta", "visible": True}` for the trace whose legend item was clicked, `visible` as it stood before the click's toggle (`True` or `"legendonly"`); trace types whose legend items are labels (pie, funnelarea) add `label`; fires on every click, and the default toggle still happens |
|
|
246
|
+
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
220
247
|
|
|
221
248
|
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
222
249
|
|
|
@@ -259,21 +286,20 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
259
286
|
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
260
287
|
|
|
261
288
|
```python
|
|
262
|
-
|
|
263
|
-
document.getElementById('{plot_id}').on('
|
|
264
|
-
Shiny.setInputValue('
|
|
265
|
-
return true; // let plotly toggle the trace as usual
|
|
289
|
+
ANNOTATION_TO_INPUT = """
|
|
290
|
+
document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
|
|
291
|
+
Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
|
|
266
292
|
});
|
|
267
293
|
"""
|
|
268
294
|
|
|
269
295
|
|
|
270
|
-
@render_plotly(post_script=
|
|
296
|
+
@render_plotly(post_script=ANNOTATION_TO_INPUT)
|
|
271
297
|
def scatter(): ...
|
|
272
298
|
```
|
|
273
299
|
|
|
274
300
|
### Live updates without a re-render
|
|
275
301
|
|
|
276
|
-
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `restyle` and `
|
|
302
|
+
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `prepend_traces`, `add_traces`, `delete_traces`, `restyle`, `relayout` and `update` call the plotly.js functions of the same names on the graph an output holds. All of them are coroutines, so the effect that calls them is `async def`.
|
|
277
303
|
|
|
278
304
|
```python
|
|
279
305
|
from shiny_plotly import extend_traces, relayout, restyle
|
|
@@ -301,9 +327,15 @@ async def _highlight():
|
|
|
301
327
|
- `extend_traces(id, data, indices=None, *, max_points=None)`: `data` maps an array attribute to one sequence of new values per trace, in the order of `indices` (`{"x": [[t]], "y": [[v]]}` appends one point to one trace; `{"y": [[1], [2]]}` with `indices=[0, 1]` one point to each of two). `indices` (an int or a list) defaults to every trace; `max_points` drops the oldest points past that many, for a rolling window.
|
|
302
328
|
- `restyle(id, update, indices=None)`: `update` maps attribute paths to values; `{"marker.color": "red"}` applies to every trace in `indices`, a list value applies per trace (`{"opacity": [0.5, 1]}` with `indices=[0, 1]`).
|
|
303
329
|
- `relayout(id, update)`: layout attribute paths, `{"title.text": "Live"}`, `{"xaxis.range": [0, 10]}`, `{"xaxis.autorange": True}`. With `events="relayout"` on the output, the result comes back as `input.<id>_relayout`, the same as a user's zoom.
|
|
330
|
+
- `update(id, restyle=None, relayout=None, indices=None)`: a restyle and a relayout in one redraw, where separate calls would draw twice. At least one of the two is required; `indices` scopes the trace part.
|
|
331
|
+
- `prepend_traces(id, data, indices=None, *, max_points=None)`: the mirror of `extend_traces`; the new values go in front, and `max_points` drops points from the far end.
|
|
332
|
+
- `add_traces(id, traces, indices=None)`: whole new traces, each a graph object (`go.Scatter(y=[1, 2])`) or a dict (`{"y": [1], "type": "bar"}`); `indices` says where they land in the trace order, appended without it.
|
|
333
|
+
- `delete_traces(id, indices)`: removes the traces at `indices`; the rest renumber, as a Python list does on `del`.
|
|
304
334
|
|
|
305
335
|
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
306
336
|
|
|
337
|
+
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
338
|
+
|
|
307
339
|
### Lower level
|
|
308
340
|
|
|
309
341
|
- `fig_to_ui(fig, div_id=None, *, height, width, figurewidget_margins, config, post_script)` returns a `TagList` holding the plotly.js dependency, the helper dependency and a `<div class="shiny-plotly">` that draws the figure with `Plotly.newPlot` (plotly's own `to_html` fragment). Use it from a plain `@render.ui` that composes a figure with other UI, or from any htmltools context. Each render draws a fresh graph; an output that is only a figure is better served by `render_plotly`.
|
|
@@ -334,11 +366,19 @@ enable_compressed_plotly_js(app)
|
|
|
334
366
|
|
|
335
367
|
And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment; `enable_compressed_plotly_js` then returns `False` and adds nothing.
|
|
336
368
|
|
|
369
|
+
### Shinylive
|
|
370
|
+
|
|
371
|
+
Apps using `shiny-plotly` run under [Shinylive](https://shiny.posit.co/py/get-started/shinylive.html) (pyodide in the browser) as well; list `shiny-plotly` in the app's `requirements.txt` next to `plotly`. There is no HTTP server in the browser and pyodide cannot start threads, so the compression route above stands down under pyodide (`enable_compressed_plotly_js` returns `False`); everything else, rendering, events, in-place updates and themes, is browser-side already. Verified against a real `shinylive export`; `examples/shinylive/` is a ready-to-export app, and its deployed copy is the [live demo](https://rvben.github.io/shiny-plotly/). The Pages workflow builds that demo from the current checkout's wheel (`make site`) and deploys only after a headless Chromium has watched both tabs render (`make site-check`).
|
|
372
|
+
|
|
337
373
|
## Examples
|
|
338
374
|
|
|
339
375
|
```sh
|
|
340
|
-
uv run --with shiny-plotly shiny run examples/core_app.py
|
|
341
|
-
uv run --with shiny-plotly shiny run examples/express_app.py
|
|
376
|
+
uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a click input, a live stream
|
|
377
|
+
uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
|
|
378
|
+
uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
|
|
379
|
+
uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
|
|
380
|
+
uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
|
|
381
|
+
uvx shinylive export examples/shinylive site # the same package, running in the browser
|
|
342
382
|
```
|
|
343
383
|
|
|
344
384
|
## Development
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Dark mode example: theme="auto" follows the page's color mode in the browser.
|
|
2
|
+
|
|
3
|
+
Run with: uv run --with shiny-plotly shiny run examples/dark_app.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import plotly.graph_objects as go
|
|
7
|
+
from shiny import App, Inputs, Outputs, Session, ui
|
|
8
|
+
|
|
9
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
10
|
+
|
|
11
|
+
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
|
|
12
|
+
TOTALS = [12, 17, 9, 20, 14, 23]
|
|
13
|
+
|
|
14
|
+
app_ui = ui.page_fillable(
|
|
15
|
+
ui.input_dark_mode(),
|
|
16
|
+
ui.layout_columns(
|
|
17
|
+
ui.card(
|
|
18
|
+
ui.card_header("theme='auto': plotly in light, plotly_dark in dark"),
|
|
19
|
+
output_plotly("auto_themed"),
|
|
20
|
+
full_screen=True,
|
|
21
|
+
),
|
|
22
|
+
ui.card(
|
|
23
|
+
ui.card_header("A custom (light, dark) pair"),
|
|
24
|
+
output_plotly("pair_themed"),
|
|
25
|
+
full_screen=True,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
title="shiny-plotly dark mode",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Any registered template name, plotly Template object or template dict works in the pair.
|
|
32
|
+
DARK_TEMPLATE = {
|
|
33
|
+
"layout": {
|
|
34
|
+
"font": {"color": "#f2f5fa"},
|
|
35
|
+
"colorway": ["#66c2a5", "#fc8d62", "#8da0cb"],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
41
|
+
# The flip happens in the browser with Plotly.relayout: no server round-trip, and it
|
|
42
|
+
# works even while the server is busy. Both templates travel with the figure, their
|
|
43
|
+
# backgrounds made transparent so the card shows through in both modes.
|
|
44
|
+
@render_plotly(theme="auto")
|
|
45
|
+
def auto_themed():
|
|
46
|
+
return go.Figure(go.Bar(x=MONTHS, y=TOTALS))
|
|
47
|
+
|
|
48
|
+
@render_plotly(theme=("seaborn", DARK_TEMPLATE))
|
|
49
|
+
def pair_themed():
|
|
50
|
+
return go.Figure(go.Scatter(x=MONTHS, y=TOTALS, mode="lines+markers"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = App(app_ui, server)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Events example: clicks and box selections arrive as Shiny inputs.
|
|
2
|
+
|
|
3
|
+
The trace holds 50 000 points, above the default max_event_points of 10 000, so a big
|
|
4
|
+
selection arrives as a count plus the box's geometry and the server recounts membership
|
|
5
|
+
from its own data, the pattern the README describes for dense traces.
|
|
6
|
+
|
|
7
|
+
Run with: uv run --with shiny-plotly shiny run examples/events_app.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from shiny import App, Inputs, Outputs, Session, render, ui
|
|
14
|
+
|
|
15
|
+
from shiny_plotly import output_plotly, render_plotly
|
|
16
|
+
|
|
17
|
+
rng = random.Random(7)
|
|
18
|
+
N = 50_000
|
|
19
|
+
X = [rng.gauss(0, 1) for _ in range(N)]
|
|
20
|
+
Y = [rng.gauss(0, 1) for _ in range(N)]
|
|
21
|
+
|
|
22
|
+
app_ui = ui.page_fillable(
|
|
23
|
+
ui.card(
|
|
24
|
+
ui.card_header("Drag a box; click a point"),
|
|
25
|
+
output_plotly("cloud"),
|
|
26
|
+
full_screen=True,
|
|
27
|
+
),
|
|
28
|
+
ui.output_text("selection_info"),
|
|
29
|
+
ui.output_text("click_info"),
|
|
30
|
+
title="shiny-plotly events",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
35
|
+
@render_plotly(events=("click", "selected"))
|
|
36
|
+
def cloud():
|
|
37
|
+
fig = go.Figure(go.Scattergl(x=X, y=Y, mode="markers", marker={"size": 3}))
|
|
38
|
+
return fig.update_layout(dragmode="select")
|
|
39
|
+
|
|
40
|
+
@render.text
|
|
41
|
+
def selection_info():
|
|
42
|
+
if not input.cloud_selected.is_set():
|
|
43
|
+
return "Selection: none yet."
|
|
44
|
+
sel = input.cloud_selected()
|
|
45
|
+
if sel is None:
|
|
46
|
+
return "Selection: cleared."
|
|
47
|
+
if sel["points"] is not None:
|
|
48
|
+
return f"Selection: {len(sel['points'])} points arrived with the event."
|
|
49
|
+
# Above max_event_points the geometry arrives instead of the points; the data
|
|
50
|
+
# lives here, so membership is a filter on the box the user dragged.
|
|
51
|
+
x0, x1 = sorted(sel["range"]["x"])
|
|
52
|
+
y0, y1 = sorted(sel["range"]["y"])
|
|
53
|
+
inside = sum(1 for x, y in zip(X, Y, strict=True) if x0 <= x <= x1 and y0 <= y <= y1)
|
|
54
|
+
return f"Selection: {sel['point_count']} points; recounted {inside} on the server."
|
|
55
|
+
|
|
56
|
+
@render.text
|
|
57
|
+
def click_info():
|
|
58
|
+
if not input.cloud_click.is_set():
|
|
59
|
+
return "Click: none yet."
|
|
60
|
+
pt = input.cloud_click()["points"][0]
|
|
61
|
+
return f"Click: point {pt['pointNumber']} at x={pt['x']:.2f}, y={pt['y']:.2f}."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
app = App(app_ui, server)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The live demo behind https://rvben.github.io/shiny-plotly/, running in the browser.
|
|
2
|
+
|
|
3
|
+
The whole app runs in the browser via pyodide: no server. Two tabs show the package's
|
|
4
|
+
two sides. Stream draws one empty figure and feeds it through ``extend_traces``, one
|
|
5
|
+
small message per tick, no re-render. Explore forwards plotly's click and box-select
|
|
6
|
+
events to Shiny inputs. Both figures follow the page's color mode via ``theme="auto"``
|
|
7
|
+
and the toggle in the corner.
|
|
8
|
+
|
|
9
|
+
Export this directory to a static site and serve it:
|
|
10
|
+
|
|
11
|
+
uvx shinylive export examples/shinylive site
|
|
12
|
+
python -m http.server 8000 -d site
|
|
13
|
+
|
|
14
|
+
That installs the released package from PyPI (requirements.txt); the deployed demo is
|
|
15
|
+
built by ``make site``, which installs the wheel from the current checkout instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
import plotly.graph_objects as go
|
|
22
|
+
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
|
|
23
|
+
|
|
24
|
+
from shiny_plotly import extend_traces, output_plotly, render_plotly
|
|
25
|
+
|
|
26
|
+
TICK_SECONDS = 0.5
|
|
27
|
+
CLUSTERS = {"alpha": (0.0, 0.0), "beta": (3.0, 2.0), "gamma": (1.0, 4.0)}
|
|
28
|
+
|
|
29
|
+
app_ui = ui.page_fillable(
|
|
30
|
+
ui.div(
|
|
31
|
+
ui.tags.b("shiny-plotly"),
|
|
32
|
+
ui.span("plotly figures in Shiny for Python, without shinywidgets", class_="text-muted"),
|
|
33
|
+
ui.div(ui.input_dark_mode(), class_="ms-auto"),
|
|
34
|
+
class_="d-flex align-items-center gap-3",
|
|
35
|
+
),
|
|
36
|
+
ui.navset_card_underline(
|
|
37
|
+
ui.nav_panel(
|
|
38
|
+
"Stream",
|
|
39
|
+
ui.layout_columns(
|
|
40
|
+
ui.input_switch("run", "Stream", value=True),
|
|
41
|
+
ui.input_slider("window", "Window (points)", min=50, max=500, value=200, step=50),
|
|
42
|
+
col_widths=(3, 9),
|
|
43
|
+
),
|
|
44
|
+
output_plotly("stream"),
|
|
45
|
+
),
|
|
46
|
+
ui.nav_panel(
|
|
47
|
+
"Explore",
|
|
48
|
+
output_plotly("explore"),
|
|
49
|
+
ui.layout_columns(ui.output_text("clicked"), ui.output_text("picked")),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
title="shiny-plotly demo",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def server(input: Inputs, output: Outputs, session: Session):
|
|
57
|
+
# Per-session: each visitor gets their own stream.
|
|
58
|
+
state = {"a": 20.0, "b": 50.0}
|
|
59
|
+
|
|
60
|
+
@render_plotly(theme="auto")
|
|
61
|
+
def stream():
|
|
62
|
+
fig = go.Figure(
|
|
63
|
+
[
|
|
64
|
+
go.Scatter(x=[], y=[], mode="lines", name="sensor a"),
|
|
65
|
+
go.Scatter(x=[], y=[], mode="lines", name="sensor b"),
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
return fig.update_layout(uirevision="keep")
|
|
69
|
+
|
|
70
|
+
@reactive.effect
|
|
71
|
+
async def _tick():
|
|
72
|
+
reactive.invalidate_later(TICK_SECONDS)
|
|
73
|
+
if not input.run():
|
|
74
|
+
return
|
|
75
|
+
now = datetime.now()
|
|
76
|
+
state["a"] += random.uniform(-1.0, 1.0)
|
|
77
|
+
state["b"] += random.uniform(-2.0, 2.0)
|
|
78
|
+
# One column of new values per trace; max_points keeps the rolling window.
|
|
79
|
+
await extend_traces(
|
|
80
|
+
"stream",
|
|
81
|
+
{"x": [[now], [now]], "y": [[state["a"]], [state["b"]]]},
|
|
82
|
+
max_points=input.window(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@render_plotly(theme="auto", events=("click", "selected"))
|
|
86
|
+
def explore():
|
|
87
|
+
rng = random.Random(7)
|
|
88
|
+
fig = go.Figure()
|
|
89
|
+
for name, (cx, cy) in CLUSTERS.items():
|
|
90
|
+
fig.add_scatter(
|
|
91
|
+
x=[cx + rng.gauss(0, 0.8) for _ in range(40)],
|
|
92
|
+
y=[cy + rng.gauss(0, 0.8) for _ in range(40)],
|
|
93
|
+
mode="markers",
|
|
94
|
+
name=name,
|
|
95
|
+
)
|
|
96
|
+
# Select mode from the start, so a plain drag is a box selection.
|
|
97
|
+
return fig.update_layout(dragmode="select")
|
|
98
|
+
|
|
99
|
+
@render.text
|
|
100
|
+
def clicked():
|
|
101
|
+
if not input.explore_click.is_set():
|
|
102
|
+
return "Click a point."
|
|
103
|
+
point = input.explore_click()["points"][0]
|
|
104
|
+
name = list(CLUSTERS)[point["curveNumber"]]
|
|
105
|
+
return f"Clicked {name} at ({point['x']:.2f}, {point['y']:.2f})."
|
|
106
|
+
|
|
107
|
+
@render.text
|
|
108
|
+
def picked():
|
|
109
|
+
if not input.explore_selected.is_set() or input.explore_selected() is None:
|
|
110
|
+
return "Drag a box to select points."
|
|
111
|
+
return f"{len(input.explore_selected()['points'])} points selected."
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
app = App(app_ui, server)
|