shiny-plotly 0.3.2__tar.gz → 0.4.0__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.2 → shiny_plotly-0.4.0}/.gitignore +1 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/CHANGELOG.md +28 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/PKG-INFO +49 -18
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/README.md +47 -15
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/core_app.py +1 -4
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/pyproject.toml +8 -7
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/__init__.py +6 -1
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_html.py +10 -4
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_render.py +84 -23
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_serve.py +95 -17
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/www/shiny-plotly.js +65 -26
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/apps.py +143 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/conftest.py +6 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_browser.py +93 -3
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_dark_mode.py +82 -0
- shiny_plotly-0.4.0/tests/helpers.py +25 -0
- shiny_plotly-0.4.0/tests/test_compressed_js.py +372 -0
- shiny_plotly-0.4.0/tests/test_examples.py +50 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_fig_to_ui.py +10 -0
- shiny_plotly-0.4.0/tests/test_pages_contract.py +50 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_render_plotly.py +244 -33
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_update.py +1 -15
- shiny_plotly-0.3.2/tests/test_compressed_js.py +0 -204
- shiny_plotly-0.3.2/tests/test_examples.py +0 -41
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/LICENSE +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/dark_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/events_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/express_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/shinylive/app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/shinylive/requirements.txt +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/streaming_app.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_update.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_events.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_shinylive_demo.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_streaming_example.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_update.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/newplot.py +0 -0
- {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0](https://github.com/rvben/shiny-plotly/compare/v0.3.3...v0.4.0) - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `brotli` is a dependency, so a plain `uv add shiny-plotly` serves plotly.min.js in the smaller encoding (1.2 MB on the wire against 1.5 MB gzipped) instead of only where someone thought to ask for it. It carries the marker `sys_platform != 'emscripten'`: under pyodide the compressing route is never installed, so a shinylive export would have downloaded a 307 kB wasm wheel to compress nothing. An install that still ends up without brotli (a lock file older than this release, a platform with no wheel) falls back to gzip and logs the same one-off warning as before, now naming the fix.
|
|
15
|
+
- README: the note on hidden outputs now names every container Shiny defers a chart in (an inactive `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden` panel, a closed `ui.accordion` section, and a false `ui.panel_conditional` from Shiny 1.6.1 on, which is where that last one changed), and says what it does not cover: a chart scrolled below the fold is drawn with the rest at load. A browser test holds every case, so a Shiny release that changes the behavior breaks the suite rather than the advice.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A client that refuses an encoding with `Accept-Encoding: br;q=0.0` is no longer served it. RFC 9110 lets a qvalue of zero be written `q=0`, `q=0.0` or `q=0.000`, and only the first spelling was read as a refusal, so plotly.min.js could arrive in an encoding the client had said it would not take.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- The `brotli` extra, now that brotli is a plain dependency. An install that still names it (`shiny-plotly[brotli]`) gets the same package, plus a warning from pip or uv that the extra does not exist; drop the `[brotli]`.
|
|
24
|
+
|
|
25
|
+
## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
- 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)`.
|
|
31
|
+
- 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.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
- 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.
|
|
37
|
+
|
|
10
38
|
## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
|
|
11
39
|
|
|
12
40
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -22,11 +22,10 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
22
22
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
23
23
|
Classifier: Typing :: Typed
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: brotli>=1.1; sys_platform != 'emscripten'
|
|
25
26
|
Requires-Dist: htmltools>=0.5.2
|
|
26
27
|
Requires-Dist: plotly>=5.5
|
|
27
28
|
Requires-Dist: shiny>=1.0
|
|
28
|
-
Provides-Extra: brotli
|
|
29
|
-
Requires-Dist: brotli>=1.1; extra == 'brotli'
|
|
30
29
|
Description-Content-Type: text/markdown
|
|
31
30
|
|
|
32
31
|
# shiny-plotly
|
|
@@ -206,6 +205,12 @@ The rules mirror `output_widget`:
|
|
|
206
205
|
|
|
207
206
|
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.
|
|
208
207
|
|
|
208
|
+
### Many charts on one page
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
The lever is drawing fewer charts at once, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
213
|
+
|
|
209
214
|
### Dark mode
|
|
210
215
|
|
|
211
216
|
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:
|
|
@@ -229,7 +234,40 @@ def sales():
|
|
|
229
234
|
def sales(): ...
|
|
230
235
|
```
|
|
231
236
|
|
|
232
|
-
How it works: both templates
|
|
237
|
+
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.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
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:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
ui.div(
|
|
245
|
+
ui.card(output_plotly("preview")), # always dark, whatever the page does
|
|
246
|
+
data_bs_theme="dark",
|
|
247
|
+
)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**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:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
app_ui = ui.page_fluid(
|
|
254
|
+
ui.input_switch("night", "Night mode"),
|
|
255
|
+
ui.tags.script("""
|
|
256
|
+
Shiny.addCustomMessageHandler("color-mode", function (message) {
|
|
257
|
+
document.documentElement.setAttribute("data-bs-theme", message.mode);
|
|
258
|
+
});
|
|
259
|
+
"""),
|
|
260
|
+
ui.card(output_plotly("sales")),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@reactive.effect
|
|
265
|
+
async def _apply_mode():
|
|
266
|
+
mode = "dark" if input.night() else "light"
|
|
267
|
+
await session.send_custom_message("color-mode", {"mode": mode})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
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.
|
|
233
271
|
|
|
234
272
|
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`:
|
|
235
273
|
|
|
@@ -373,29 +411,22 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
373
411
|
- `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.
|
|
374
412
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
375
413
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
376
|
-
- `enable_compressed_plotly_js(app)`
|
|
414
|
+
- `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).
|
|
377
415
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
378
416
|
|
|
379
417
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
380
418
|
|
|
381
419
|
### plotly.js on the wire
|
|
382
420
|
|
|
383
|
-
Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so
|
|
421
|
+
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, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) 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.
|
|
384
422
|
|
|
385
|
-
|
|
386
|
-
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
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:
|
|
423
|
+
`brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and 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.
|
|
390
424
|
|
|
391
|
-
|
|
392
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
425
|
+
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.
|
|
393
426
|
|
|
394
|
-
app
|
|
395
|
-
enable_compressed_plotly_js(app)
|
|
396
|
-
```
|
|
427
|
+
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.
|
|
397
428
|
|
|
398
|
-
|
|
429
|
+
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`.
|
|
399
430
|
|
|
400
431
|
### Shinylive
|
|
401
432
|
|
|
@@ -422,7 +453,7 @@ make bench # the shinywidgets comparison above, on this machine
|
|
|
422
453
|
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
423
454
|
```
|
|
424
455
|
|
|
425
|
-
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
456
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
426
457
|
|
|
427
458
|
## License
|
|
428
459
|
|
|
@@ -175,6 +175,12 @@ The rules mirror `output_widget`:
|
|
|
175
175
|
|
|
176
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.
|
|
177
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, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
|
|
183
|
+
|
|
178
184
|
### Dark mode
|
|
179
185
|
|
|
180
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:
|
|
@@ -198,7 +204,40 @@ def sales():
|
|
|
198
204
|
def sales(): ...
|
|
199
205
|
```
|
|
200
206
|
|
|
201
|
-
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.
|
|
202
241
|
|
|
203
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`:
|
|
204
243
|
|
|
@@ -342,29 +381,22 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
|
|
|
342
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.
|
|
343
382
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
344
383
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
345
|
-
- `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).
|
|
346
385
|
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
347
386
|
|
|
348
387
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
349
388
|
|
|
350
389
|
### plotly.js on the wire
|
|
351
390
|
|
|
352
|
-
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, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) 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.
|
|
353
392
|
|
|
354
|
-
|
|
355
|
-
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
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:
|
|
393
|
+
`brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and 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.
|
|
359
394
|
|
|
360
|
-
|
|
361
|
-
from shiny_plotly import enable_compressed_plotly_js
|
|
395
|
+
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.
|
|
362
396
|
|
|
363
|
-
app
|
|
364
|
-
enable_compressed_plotly_js(app)
|
|
365
|
-
```
|
|
397
|
+
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.
|
|
366
398
|
|
|
367
|
-
|
|
399
|
+
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`.
|
|
368
400
|
|
|
369
401
|
### Shinylive
|
|
370
402
|
|
|
@@ -391,7 +423,7 @@ make bench # the shinywidgets comparison above, on this machine
|
|
|
391
423
|
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
392
424
|
```
|
|
393
425
|
|
|
394
|
-
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
426
|
+
`make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
|
|
395
427
|
|
|
396
428
|
## License
|
|
397
429
|
|
|
@@ -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)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiny-plotly"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -33,12 +33,12 @@ dependencies = [
|
|
|
33
33
|
"shiny>=1.0",
|
|
34
34
|
"plotly>=5.5",
|
|
35
35
|
"htmltools>=0.5.2",
|
|
36
|
+
# plotly.min.js is served brotli-compressed, about 17% smaller than gzip. Under pyodide
|
|
37
|
+
# there is nothing to compress (the bundle comes from the shinylive export) and the route
|
|
38
|
+
# is skipped, so the marker keeps a 307 kB wasm wheel out of every exported app.
|
|
39
|
+
"brotli>=1.1; sys_platform != 'emscripten'",
|
|
36
40
|
]
|
|
37
41
|
|
|
38
|
-
[project.optional-dependencies]
|
|
39
|
-
# plotly.min.js is served brotli-compressed (about 15% smaller than gzip) when present.
|
|
40
|
-
brotli = ["brotli>=1.1"]
|
|
41
|
-
|
|
42
42
|
[project.urls]
|
|
43
43
|
Homepage = "https://github.com/rvben/shiny-plotly"
|
|
44
44
|
Repository = "https://github.com/rvben/shiny-plotly"
|
|
@@ -52,12 +52,13 @@ bench = [
|
|
|
52
52
|
]
|
|
53
53
|
dev = [
|
|
54
54
|
"pytest>=8",
|
|
55
|
+
"pytest-cov>=5",
|
|
55
56
|
"ruff>=0.6",
|
|
56
57
|
"pyright>=1.1.380",
|
|
57
58
|
"httpx2>=2.12.0",
|
|
58
59
|
"pytest-playwright>=0.9.0",
|
|
59
60
|
"numpy>=1.26",
|
|
60
|
-
"
|
|
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
|
|
@@ -90,4 +91,4 @@ pythonVersion = "3.10"
|
|
|
90
91
|
typeCheckingMode = "standard"
|
|
91
92
|
|
|
92
93
|
[tool.ruff.lint.isort]
|
|
93
|
-
known-local-folder = ["newplot"]
|
|
94
|
+
known-local-folder = ["helpers", "newplot"]
|
|
@@ -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()
|
|
@@ -103,9 +103,15 @@ def fig_to_ui(
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def as_fig_dict(fig: Figure) -> dict[str, Any]:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
"""
|
|
107
|
+
The figure as a plain dict carrying a ``layout`` dict, whichever way it was given.
|
|
108
|
+
|
|
109
|
+
Everything downstream reaches into ``layout`` (to fill in margins, to drop a baked-in
|
|
110
|
+
template), so it is made a dict here rather than guarded against at each of them.
|
|
111
|
+
"""
|
|
112
|
+
# Figure.to_dict() does no validation (the figure was validated when built), and always
|
|
113
|
+
# carries a layout; a dict is passed through as the caller's JSON, so pio.to_html gets
|
|
114
|
+
# validate=False and never reconstructs a Figure from it.
|
|
109
115
|
if isinstance(fig, BaseFigure):
|
|
110
116
|
return fig.to_dict()
|
|
111
117
|
if isinstance(fig, dict):
|
|
@@ -116,5 +122,5 @@ def as_fig_dict(fig: Figure) -> dict[str, Any]:
|
|
|
116
122
|
|
|
117
123
|
|
|
118
124
|
def fill_in_margins(fig_dict: dict[str, Any]) -> None:
|
|
119
|
-
layout = fig_dict
|
|
125
|
+
layout = fig_dict["layout"]
|
|
120
126
|
layout["margin"] = {**FIGUREWIDGET_MARGINS, **(layout.get("margin") or {})}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import hashlib
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
|
4
6
|
from typing import Any
|
|
5
7
|
|
|
6
8
|
import plotly.io as pio
|
|
@@ -8,6 +10,7 @@ from htmltools import Tag, css, tags
|
|
|
8
10
|
from plotly.io.json import to_json_plotly
|
|
9
11
|
from shiny.module import resolve_id
|
|
10
12
|
from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
|
|
13
|
+
from shiny.session import Session, get_current_session
|
|
11
14
|
from shiny.ui.fill import as_fill_item, as_fillable_container
|
|
12
15
|
|
|
13
16
|
from ._deps import plotly_js, shiny_plotly_js
|
|
@@ -102,6 +105,27 @@ def _template_json(spec: TemplateSpec) -> dict[str, Any]:
|
|
|
102
105
|
return out
|
|
103
106
|
|
|
104
107
|
|
|
108
|
+
# Name of the custom message carrying templates the session has not seen yet.
|
|
109
|
+
TEMPLATE_MESSAGE = "shiny-plotly-template"
|
|
110
|
+
|
|
111
|
+
# Keys of the templates each session has already been sent, by root session. A template is
|
|
112
|
+
# about 6.5 kB and every chart on a page usually shares the same one, so it travels once
|
|
113
|
+
# per session and the values afterwards carry only its key. The session holds the set, and
|
|
114
|
+
# a browser that reconnects gets a new session, so the server can never believe the browser
|
|
115
|
+
# has a template it does not; a weak key lets both go when the session ends.
|
|
116
|
+
_sent_templates: MutableMapping[Session, set[str]] = weakref.WeakKeyDictionary()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def template_body(spec: TemplateSpec) -> str:
|
|
120
|
+
"""One template as the JSON it travels as, through plotly's own encoder."""
|
|
121
|
+
return str(to_json_plotly(_template_json(spec)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def template_key(template_json: str) -> str:
|
|
125
|
+
"""Content address of one serialized template; equal templates share one key."""
|
|
126
|
+
return hashlib.sha256(template_json.encode(), usedforsecurity=False).hexdigest()[:16]
|
|
127
|
+
|
|
128
|
+
|
|
105
129
|
def normalize_max_event_points(value: int | None) -> int | None:
|
|
106
130
|
if value is None:
|
|
107
131
|
return None
|
|
@@ -211,14 +235,17 @@ class render_plotly(Renderer[Figure]):
|
|
|
211
235
|
round-trip. ``"auto"`` uses plotly's own pair: the ``"plotly"`` template in light
|
|
212
236
|
mode and ``"plotly_dark"`` in dark. A ``(light, dark)`` pair picks the templates,
|
|
213
237
|
each a registered name (``"seaborn"``), a plotly ``Template`` object or a template
|
|
214
|
-
dict. Both templates
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
238
|
+
dict. Both templates get transparent ``paper_bgcolor`` and ``plot_bgcolor`` so the
|
|
239
|
+
page shows through (a figure-level background still wins), and a template the
|
|
240
|
+
figure baked in via ``layout.template`` is dropped. Each template travels once per
|
|
241
|
+
session, so a page of charts sharing a theme sends one copy of it and every render
|
|
242
|
+
after that carries only its key. The browser applies the mode's template before the
|
|
243
|
+
first draw and switches it with ``Plotly.relayout`` when the mode flips: the mode is
|
|
244
|
+
the ``data-bs-theme`` of the output's nearest ancestor that sets one (what
|
|
245
|
+
``ui.input_dark_mode()`` sets on ``<html>``, or a container that themes part of a
|
|
246
|
+
page), the OS ``prefers-color-scheme`` otherwise. Template names resolve when the
|
|
247
|
+
decorator runs; an unknown name raises right there. Default ``None``: the figure's
|
|
248
|
+
own template, fixed.
|
|
222
249
|
"""
|
|
223
250
|
|
|
224
251
|
def __init__(
|
|
@@ -242,15 +269,21 @@ class render_plotly(Renderer[Figure]):
|
|
|
242
269
|
self.events = normalize_events(events)
|
|
243
270
|
self.max_event_points = normalize_max_event_points(max_event_points)
|
|
244
271
|
self.theme = normalize_theme(theme)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
272
|
+
# Resolved and serialized once, here, so an unknown name fails at decoration time
|
|
273
|
+
# and a render costs nothing extra.
|
|
274
|
+
self._theme_templates: dict[str, str] = {}
|
|
275
|
+
self._theme_keys: dict[str, Any] | None = None
|
|
276
|
+
self._themes_json: str | None = None
|
|
277
|
+
if self.theme is not None:
|
|
278
|
+
light, dark = (template_body(spec) for spec in self.theme)
|
|
279
|
+
self._theme_keys = {"light": template_key(light), "dark": template_key(dark)}
|
|
280
|
+
self._theme_templates = {
|
|
281
|
+
self._theme_keys["light"]: light,
|
|
282
|
+
self._theme_keys["dark"]: dark,
|
|
283
|
+
}
|
|
284
|
+
# The inline form, for a render with no session to cache against. Built from
|
|
285
|
+
# the same two strings, so both paths carry byte-identical templates.
|
|
286
|
+
self._themes_json = f'{{"light":{light},"dark":{dark}}}'
|
|
254
287
|
# Registers _fn (sets output_id from its name) when used as a bare decorator.
|
|
255
288
|
super().__init__(_fn) # type: ignore[arg-type]
|
|
256
289
|
|
|
@@ -265,16 +298,43 @@ class render_plotly(Renderer[Figure]):
|
|
|
265
298
|
def auto_output_ui(self) -> Tag:
|
|
266
299
|
return output_plotly(self.output_id)
|
|
267
300
|
|
|
301
|
+
async def _cache_templates(self) -> dict[str, Any] | None:
|
|
302
|
+
"""
|
|
303
|
+
The keys of this renderer's templates, once the session is known to hold them.
|
|
304
|
+
|
|
305
|
+
``None`` when there is no session to cache against (a render outside a session, or
|
|
306
|
+
Express's stub session, which drops custom messages), and the templates then travel
|
|
307
|
+
inline with the value instead.
|
|
308
|
+
"""
|
|
309
|
+
session = get_current_session()
|
|
310
|
+
if session is None or session.is_stub_session():
|
|
311
|
+
return None
|
|
312
|
+
sent = _sent_templates.setdefault(session.root_scope(), set())
|
|
313
|
+
new = {key: body for key, body in self._theme_templates.items() if key not in sent}
|
|
314
|
+
if new:
|
|
315
|
+
# Sent from inside the render, so it is its own websocket message ahead of the
|
|
316
|
+
# one carrying the values: the browser has the template before it is asked to
|
|
317
|
+
# draw with it. Batching it into the value message would invert that, since
|
|
318
|
+
# Shiny dispatches its custom handlers after its output values.
|
|
319
|
+
await session.send_custom_message(TEMPLATE_MESSAGE, {"templates": new})
|
|
320
|
+
# Recorded only once the send has gone through. A repeat send costs a message
|
|
321
|
+
# the browser overwrites with the same bytes, while a key recorded for a
|
|
322
|
+
# template that never arrived would leave the figure unthemed for the session.
|
|
323
|
+
sent.update(new)
|
|
324
|
+
return self._theme_keys
|
|
325
|
+
|
|
268
326
|
async def transform(self, value: Figure) -> Jsonifiable:
|
|
269
327
|
fig_dict = as_fig_dict(value)
|
|
270
328
|
if self.figurewidget_margins:
|
|
271
329
|
fill_in_margins(fig_dict)
|
|
272
|
-
|
|
330
|
+
themes_json, theme_keys = self._themes_json, None
|
|
331
|
+
if self._theme_templates:
|
|
273
332
|
# The browser picks the mode's template; the one the figure baked in at
|
|
274
333
|
# construction would only add dead weight and a flash of the wrong theme.
|
|
275
|
-
layout
|
|
276
|
-
|
|
277
|
-
|
|
334
|
+
fig_dict["layout"].pop("template", None)
|
|
335
|
+
theme_keys = await self._cache_templates()
|
|
336
|
+
if theme_keys is not None:
|
|
337
|
+
themes_json = None
|
|
278
338
|
return {
|
|
279
339
|
# Serialised by plotly, not Shiny: numpy and pandas values, datetimes and the
|
|
280
340
|
# compact base64 array encoding only work through plotly's encoder.
|
|
@@ -285,5 +345,6 @@ class render_plotly(Renderer[Figure]):
|
|
|
285
345
|
"post_script": self.post_script,
|
|
286
346
|
"events": list(self.events),
|
|
287
347
|
"max_event_points": self.max_event_points,
|
|
288
|
-
"themes":
|
|
348
|
+
"themes": themes_json,
|
|
349
|
+
"theme_keys": theme_keys,
|
|
289
350
|
}
|