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.
Files changed (39) hide show
  1. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/.gitignore +1 -0
  2. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/CHANGELOG.md +21 -0
  3. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/PKG-INFO +61 -18
  4. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/README.md +60 -17
  5. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/core_app.py +1 -4
  6. shiny_plotly-0.3.3/examples/shinylive/app.py +114 -0
  7. shiny_plotly-0.3.3/examples/streaming_app.py +69 -0
  8. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/pyproject.toml +3 -2
  9. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/__init__.py +6 -1
  10. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_render.py +106 -27
  11. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_serve.py +75 -15
  12. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/www/shiny-plotly.js +91 -26
  13. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/apps.py +94 -0
  14. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/conftest.py +21 -0
  15. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_browser.py +13 -0
  16. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_dark_mode.py +82 -0
  17. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_events.py +53 -0
  18. shiny_plotly-0.3.3/tests/browser/test_shinylive_demo.py +48 -0
  19. shiny_plotly-0.3.3/tests/browser/test_streaming_example.py +39 -0
  20. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_compressed_js.py +118 -5
  21. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_examples.py +1 -0
  22. shiny_plotly-0.3.3/tests/test_pages_contract.py +50 -0
  23. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_render_plotly.py +203 -30
  24. shiny_plotly-0.3.1/examples/shinylive/app.py +0 -33
  25. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/LICENSE +0 -0
  26. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/dark_app.py +0 -0
  27. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/events_app.py +0 -0
  28. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/express_app.py +0 -0
  29. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/examples/shinylive/requirements.txt +0 -0
  30. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_deps.py +0 -0
  31. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_html.py +0 -0
  32. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/_update.py +0 -0
  33. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/src/shiny_plotly/py.typed +0 -0
  34. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/__init__.py +0 -0
  35. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/browser/test_update.py +0 -0
  36. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/newplot.py +0 -0
  37. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_fig_to_ui.py +0 -0
  38. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_plotly_js.py +0 -0
  39. {shiny_plotly-0.3.1 → shiny_plotly-0.3.3}/tests/test_update.py +0 -0
@@ -7,3 +7,4 @@ __pycache__/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
9
  tmp/
10
+ site/
@@ -7,6 +7,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.1
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
  [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
39
39
  [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
40
40
 
41
+ **[Try the live demo](https://rvben.github.io/shiny-plotly/)**: a streaming figure and forwarded plotly events, running entirely in your browser through shinylive.
42
+
41
43
  ```python
42
44
  from shiny_plotly import output_plotly, render_plotly
43
45
 
@@ -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 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.
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. Four are available: `click`, `hover`, `selected` and `relayout`.
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
- LEGEND_TO_INPUT = """
316
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
317
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
318
- return true; // let plotly toggle the trace as usual
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=LEGEND_TO_INPUT)
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)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
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 once the first session of a process has rendered a figure, `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.
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
- Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. A Core app can close that gap by enabling the route as soon as the `App` exists:
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
- ```python
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 = App(app_ui, server)
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
- 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.
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
  [![PyPI](https://img.shields.io/pypi/v/shiny-plotly)](https://pypi.org/project/shiny-plotly/)
8
8
  [![CI](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml/badge.svg)](https://github.com/rvben/shiny-plotly/actions/workflows/ci.yml)
9
9
 
10
+ **[Try the live demo](https://rvben.github.io/shiny-plotly/)**: a streaming figure and forwarded plotly events, running entirely in your browser through shinylive.
11
+
10
12
  ```python
11
13
  from shiny_plotly import output_plotly, render_plotly
12
14
 
@@ -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 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.
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. Four are available: `click`, `hover`, `selected` and `relayout`.
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
- LEGEND_TO_INPUT = """
285
- document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
286
- Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
287
- return true; // let plotly toggle the trace as usual
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=LEGEND_TO_INPUT)
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)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
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 once the first session of a process has rendered a figure, `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.
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
- Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. A Core app can close that gap by enabling the route as soon as the `App` exists:
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
- ```python
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 = App(app_ui, server)
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
- 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.
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 enable_compressed_plotly_js, extend_traces, output_plotly, render_plotly
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.1"
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()