shiny-plotly 0.3.1__tar.gz → 0.3.3__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.3}/.gitignore +1 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/CHANGELOG.md +21 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/PKG-INFO +61 -18
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/README.md +60 -17
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/core_app.py +1 -4
- shiny_plotly-0.3.3/examples/shinylive/app.py +114 -0
- shiny_plotly-0.3.3/examples/streaming_app.py +69 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/pyproject.toml +3 -2
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/__init__.py +6 -1
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_render.py +106 -27
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_serve.py +75 -15
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/www/shiny-plotly.js +91 -26
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/apps.py +94 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/conftest.py +21 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_browser.py +13 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_dark_mode.py +82 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_events.py +53 -0
- shiny_plotly-0.3.3/tests/browser/test_shinylive_demo.py +48 -0
- shiny_plotly-0.3.3/tests/browser/test_streaming_example.py +39 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_compressed_js.py +118 -5
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_examples.py +1 -0
- shiny_plotly-0.3.3/tests/test_pages_contract.py +50 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_render_plotly.py +203 -30
- shiny_plotly-0.3.1/examples/shinylive/app.py +0 -33
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/LICENSE +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_update.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_update.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_plotly_js.py +0 -0
- {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_update.py +0 -0
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A themed chart takes its color mode from the nearest ancestor that carries `data-bs-theme`, itself included, instead of from `<html>` alone. Bootstrap honors the attribute on any element, so `ui.div(..., data_bs_theme="dark")` around part of a page now themes the charts inside it while the rest of the page follows the page mode. The observer watches the whole subtree, so the attribute can be set on a container after the charts are drawn; that is all a control of your own needs to drive the mode, and the README carries the recipe.
|
|
15
|
+
- A warning, said once per process, when `plotly.min.js` is served gzipped because `brotli` is not installed: it names the size being served and the roughly 17% brotli would save. Without it a deployment has no way to notice, since the bundle is compressed, cached and immutable either way, just bigger. Silence it with `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)`.
|
|
16
|
+
- README: what each chart on a page costs to draw, and that Shiny suspends outputs hidden in an inactive `ui.nav_panel`, so charts in tabs are drawn when their tab is first opened rather than on load.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Every app serves plotly.min.js compressed and immutable from its first request, with no call of its own: importing `shiny_plotly` wraps `shiny.App.__init__`, so the route is added as each app is built, Core and Express alike. Until now it arrived with the first session, which is after the page load that opened that session had already pulled 4.9 MB from Shiny's static mount with no `Cache-Control` at all; the background compression now starts while the app is being built instead of when the first visitor is already waiting. `enable_compressed_plotly_js(app)` stays for an app constructed before the import, `SHINY_PLOTLY_NO_COMPRESS=1` still leaves Shiny's static serving untouched, and a failure to install the route is logged rather than raised: it can never keep an app from being built.
|
|
21
|
+
- The templates behind `@render_plotly(theme=...)` travel once per session instead of once per figure. A template is about 6.5 kB, and every chart on a page usually shares one pair, so the first themed value carries the pair and every value afterwards names it by content hash: a page of ten charts sends about 13 kB of templates instead of 135 kB. Two outputs on different themes each send their own pair, a module shares the cache with the page around it, and a reconnecting browser gets a new session and so a fresh copy. A render with no session to cache against (a value produced outside a session, or Express's stub session before it connects) still carries its templates inline.
|
|
22
|
+
|
|
23
|
+
## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
- `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.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
10
31
|
## [0.3.1](https://github.com/rvben/shiny-plotly/compare/v0.3.0...v0.3.1) - 2026-08-20
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
|
@@ -204,6 +206,12 @@ The rules mirror `output_widget`:
|
|
|
204
206
|
|
|
205
207
|
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
206
208
|
|
|
209
|
+
### Many charts on one page
|
|
210
|
+
|
|
211
|
+
Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
|
|
212
|
+
|
|
213
|
+
The lever is drawing fewer charts at once. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
214
|
+
|
|
207
215
|
### Dark mode
|
|
208
216
|
|
|
209
217
|
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:
|
|
@@ -227,7 +235,40 @@ def sales():
|
|
|
227
235
|
def sales(): ...
|
|
228
236
|
```
|
|
229
237
|
|
|
230
|
-
How it works: both templates
|
|
238
|
+
How it works: both templates get 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. 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.
|
|
239
|
+
|
|
240
|
+
A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
|
|
241
|
+
|
|
242
|
+
The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
ui.div(
|
|
246
|
+
ui.card(output_plotly("preview")), # always dark, whatever the page does
|
|
247
|
+
data_bs_theme="dark",
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
app_ui = ui.page_fluid(
|
|
255
|
+
ui.input_switch("night", "Night mode"),
|
|
256
|
+
ui.tags.script("""
|
|
257
|
+
Shiny.addCustomMessageHandler("color-mode", function (message) {
|
|
258
|
+
document.documentElement.setAttribute("data-bs-theme", message.mode);
|
|
259
|
+
});
|
|
260
|
+
"""),
|
|
261
|
+
ui.card(output_plotly("sales")),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@reactive.effect
|
|
266
|
+
async def _apply_mode():
|
|
267
|
+
mode = "dark" if input.night() else "light"
|
|
268
|
+
await session.send_custom_message("color-mode", {"mode": mode})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
|
|
231
272
|
|
|
232
273
|
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`:
|
|
233
274
|
|
|
@@ -247,7 +288,7 @@ def sales():
|
|
|
247
288
|
|
|
248
289
|
### Events back to Shiny
|
|
249
290
|
|
|
250
|
-
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module.
|
|
291
|
+
`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
292
|
|
|
252
293
|
```python
|
|
253
294
|
@render_plotly(events=("click", "selected"))
|
|
@@ -270,6 +311,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
270
311
|
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
271
312
|
| `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
313
|
| `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 |
|
|
314
|
+
| `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 |
|
|
315
|
+
| `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 |
|
|
316
|
+
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
273
317
|
|
|
274
318
|
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
319
|
|
|
@@ -312,15 +356,14 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
312
356
|
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
357
|
|
|
314
358
|
```python
|
|
315
|
-
|
|
316
|
-
document.getElementById('{plot_id}').on('
|
|
317
|
-
Shiny.setInputValue('
|
|
318
|
-
return true; // let plotly toggle the trace as usual
|
|
359
|
+
ANNOTATION_TO_INPUT = """
|
|
360
|
+
document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
|
|
361
|
+
Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
|
|
319
362
|
});
|
|
320
363
|
"""
|
|
321
364
|
|
|
322
365
|
|
|
323
|
-
@render_plotly(post_script=
|
|
366
|
+
@render_plotly(post_script=ANNOTATION_TO_INPUT)
|
|
324
367
|
def scatter(): ...
|
|
325
368
|
```
|
|
326
369
|
|
|
@@ -361,39 +404,38 @@ async def _highlight():
|
|
|
361
404
|
|
|
362
405
|
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
406
|
|
|
407
|
+
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
408
|
+
|
|
364
409
|
### Lower level
|
|
365
410
|
|
|
366
411
|
- `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`.
|
|
367
412
|
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
368
413
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
369
414
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
370
|
-
- `enable_compressed_plotly_js(app)`
|
|
415
|
+
- `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
|
|
371
416
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
372
417
|
|
|
373
418
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
374
419
|
|
|
375
420
|
### plotly.js on the wire
|
|
376
421
|
|
|
377
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so
|
|
422
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
|
|
378
423
|
|
|
379
424
|
```sh
|
|
380
425
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
381
426
|
```
|
|
382
427
|
|
|
383
|
-
|
|
428
|
+
Without it the process logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
384
429
|
|
|
385
|
-
|
|
386
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
430
|
+
The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
|
|
387
431
|
|
|
388
|
-
app
|
|
389
|
-
enable_compressed_plotly_js(app)
|
|
390
|
-
```
|
|
432
|
+
An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
|
|
391
433
|
|
|
392
|
-
|
|
434
|
+
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: no route is added, and `enable_compressed_plotly_js` returns `False`.
|
|
393
435
|
|
|
394
436
|
### Shinylive
|
|
395
437
|
|
|
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.
|
|
438
|
+
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
439
|
|
|
398
440
|
## Examples
|
|
399
441
|
|
|
@@ -402,6 +444,7 @@ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a
|
|
|
402
444
|
uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
|
|
403
445
|
uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
|
|
404
446
|
uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
|
|
447
|
+
uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
|
|
405
448
|
uvx shinylive export examples/shinylive site # the same package, running in the browser
|
|
406
449
|
```
|
|
407
450
|
|
|
@@ -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
|
|
|
@@ -173,6 +175,12 @@ The rules mirror `output_widget`:
|
|
|
173
175
|
|
|
174
176
|
Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
|
|
175
177
|
|
|
178
|
+
### Many charts on one page
|
|
179
|
+
|
|
180
|
+
Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
|
|
181
|
+
|
|
182
|
+
The lever is drawing fewer charts at once. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
183
|
+
|
|
176
184
|
### Dark mode
|
|
177
185
|
|
|
178
186
|
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:
|
|
@@ -196,7 +204,40 @@ def sales():
|
|
|
196
204
|
def sales(): ...
|
|
197
205
|
```
|
|
198
206
|
|
|
199
|
-
How it works: both templates
|
|
207
|
+
How it works: both templates get 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. 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.
|
|
208
|
+
|
|
209
|
+
A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
|
|
210
|
+
|
|
211
|
+
The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
ui.div(
|
|
215
|
+
ui.card(output_plotly("preview")), # always dark, whatever the page does
|
|
216
|
+
data_bs_theme="dark",
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
app_ui = ui.page_fluid(
|
|
224
|
+
ui.input_switch("night", "Night mode"),
|
|
225
|
+
ui.tags.script("""
|
|
226
|
+
Shiny.addCustomMessageHandler("color-mode", function (message) {
|
|
227
|
+
document.documentElement.setAttribute("data-bs-theme", message.mode);
|
|
228
|
+
});
|
|
229
|
+
"""),
|
|
230
|
+
ui.card(output_plotly("sales")),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@reactive.effect
|
|
235
|
+
async def _apply_mode():
|
|
236
|
+
mode = "dark" if input.night() else "light"
|
|
237
|
+
await session.send_custom_message("color-mode", {"mode": mode})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
|
|
200
241
|
|
|
201
242
|
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`:
|
|
202
243
|
|
|
@@ -216,7 +257,7 @@ def sales():
|
|
|
216
257
|
|
|
217
258
|
### Events back to Shiny
|
|
218
259
|
|
|
219
|
-
`events=` names the plotly events to forward; each arrives as `input.<id>_<event>`, namespaced like the output inside a module.
|
|
260
|
+
`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
261
|
|
|
221
262
|
```python
|
|
222
263
|
@render_plotly(events=("click", "selected"))
|
|
@@ -239,6 +280,9 @@ What arrives is plotly's own event data, cut to what serializes, the same way Da
|
|
|
239
280
|
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
240
281
|
| `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
282
|
| `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 |
|
|
283
|
+
| `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 |
|
|
284
|
+
| `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 |
|
|
285
|
+
| `legenddoubleclick` | same value as `legendclick`; the default isolate-this-trace behavior still happens |
|
|
242
286
|
|
|
243
287
|
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
288
|
|
|
@@ -281,15 +325,14 @@ The value is never silently cut: `points` is a full list or `None`, and `point_c
|
|
|
281
325
|
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
326
|
|
|
283
327
|
```python
|
|
284
|
-
|
|
285
|
-
document.getElementById('{plot_id}').on('
|
|
286
|
-
Shiny.setInputValue('
|
|
287
|
-
return true; // let plotly toggle the trace as usual
|
|
328
|
+
ANNOTATION_TO_INPUT = """
|
|
329
|
+
document.getElementById('{plot_id}').on('plotly_clickannotation', function (ev) {
|
|
330
|
+
Shiny.setInputValue('annotation', ev.index, {priority: 'event'});
|
|
288
331
|
});
|
|
289
332
|
"""
|
|
290
333
|
|
|
291
334
|
|
|
292
|
-
@render_plotly(post_script=
|
|
335
|
+
@render_plotly(post_script=ANNOTATION_TO_INPUT)
|
|
293
336
|
def scatter(): ...
|
|
294
337
|
```
|
|
295
338
|
|
|
@@ -330,39 +373,38 @@ async def _highlight():
|
|
|
330
373
|
|
|
331
374
|
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
375
|
|
|
376
|
+
A runnable version of the streaming pattern, with a pause switch and a window slider, is `examples/streaming_app.py`.
|
|
377
|
+
|
|
333
378
|
### Lower level
|
|
334
379
|
|
|
335
380
|
- `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`.
|
|
336
381
|
- `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
|
|
337
382
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
338
383
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
339
|
-
- `enable_compressed_plotly_js(app)`
|
|
384
|
+
- `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
|
|
340
385
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
341
386
|
|
|
342
387
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
343
388
|
|
|
344
389
|
### plotly.js on the wire
|
|
345
390
|
|
|
346
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so
|
|
391
|
+
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
|
|
347
392
|
|
|
348
393
|
```sh
|
|
349
394
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
350
395
|
```
|
|
351
396
|
|
|
352
|
-
|
|
397
|
+
Without it the process logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
|
|
353
398
|
|
|
354
|
-
|
|
355
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
399
|
+
The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
|
|
356
400
|
|
|
357
|
-
app
|
|
358
|
-
enable_compressed_plotly_js(app)
|
|
359
|
-
```
|
|
401
|
+
An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
|
|
360
402
|
|
|
361
|
-
|
|
403
|
+
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: no route is added, and `enable_compressed_plotly_js` returns `False`.
|
|
362
404
|
|
|
363
405
|
### Shinylive
|
|
364
406
|
|
|
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.
|
|
407
|
+
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
408
|
|
|
367
409
|
## Examples
|
|
368
410
|
|
|
@@ -371,6 +413,7 @@ uv run --with shiny-plotly shiny run examples/core_app.py # fill, margins, a
|
|
|
371
413
|
uv run --with shiny-plotly shiny run examples/express_app.py # the Express flavor
|
|
372
414
|
uv run --with shiny-plotly shiny run examples/dark_app.py # theme="auto" and a custom (light, dark) pair
|
|
373
415
|
uv run --with shiny-plotly shiny run examples/events_app.py # box selections over a 50k-point trace
|
|
416
|
+
uv run --with shiny-plotly shiny run examples/streaming_app.py # a rolling window fed by extend_traces
|
|
374
417
|
uvx shinylive export examples/shinylive site # the same package, running in the browser
|
|
375
418
|
```
|
|
376
419
|
|
|
@@ -9,7 +9,7 @@ from itertools import accumulate
|
|
|
9
9
|
import plotly.graph_objects as go
|
|
10
10
|
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
|
|
11
11
|
|
|
12
|
-
from shiny_plotly import
|
|
12
|
+
from shiny_plotly import extend_traces, output_plotly, render_plotly
|
|
13
13
|
|
|
14
14
|
app_ui = ui.page_sidebar(
|
|
15
15
|
ui.sidebar(
|
|
@@ -99,6 +99,3 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
app = App(app_ui, server)
|
|
102
|
-
# Optional: serve plotly.js compressed from the very first request instead of from the
|
|
103
|
-
# first session on (see README, "plotly.js on the wire").
|
|
104
|
-
enable_compressed_plotly_js(app)
|
|
@@ -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.3"
|
|
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"
|
|
@@ -58,6 +58,7 @@ dev = [
|
|
|
58
58
|
"pytest-playwright>=0.9.0",
|
|
59
59
|
"numpy>=1.26",
|
|
60
60
|
"brotli>=1.1",
|
|
61
|
+
"shinylive-check>=0.1.0",
|
|
61
62
|
]
|
|
62
63
|
|
|
63
64
|
# uv's default cache keys for a local build are the metadata files only, so a source-only
|
|
@@ -85,7 +86,7 @@ target-version = "py310"
|
|
|
85
86
|
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
86
87
|
|
|
87
88
|
[tool.pyright]
|
|
88
|
-
include = ["src", "tests", "examples", "bench"]
|
|
89
|
+
include = ["src", "tests", "examples", "bench", "tools"]
|
|
89
90
|
pythonVersion = "3.10"
|
|
90
91
|
typeCheckingMode = "standard"
|
|
91
92
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from ._deps import __version__, plotly_js, shiny_plotly_js
|
|
4
4
|
from ._html import FIGUREWIDGET_MARGINS, fig_to_ui
|
|
5
5
|
from ._render import DEFAULT_MAX_EVENT_POINTS, output_plotly, render_plotly
|
|
6
|
-
from ._serve import enable_compressed_plotly_js
|
|
6
|
+
from ._serve import enable_compressed_plotly_js, enable_for_new_apps
|
|
7
7
|
from ._update import (
|
|
8
8
|
add_traces,
|
|
9
9
|
delete_traces,
|
|
@@ -32,3 +32,8 @@ __all__ = (
|
|
|
32
32
|
"shiny_plotly_js",
|
|
33
33
|
"update",
|
|
34
34
|
)
|
|
35
|
+
|
|
36
|
+
# From here on every app serves plotly.min.js compressed and immutable, from its first
|
|
37
|
+
# request, with no call of its own; the compression starts while the app is being built.
|
|
38
|
+
# SHINY_PLOTLY_NO_COMPRESS=1 in the environment leaves Shiny's static serving untouched.
|
|
39
|
+
enable_for_new_apps()
|