shiny-plotly 0.2.0__tar.gz → 0.3.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.2.0 → shiny_plotly-0.3.0}/.gitignore +1 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/CHANGELOG.md +14 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/PKG-INFO +141 -22
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/README.md +138 -19
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/examples/core_app.py +36 -16
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/pyproject.toml +6 -6
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/src/shiny_plotly/__init__.py +8 -1
- shiny_plotly-0.3.0/src/shiny_plotly/_render.py +188 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/src/shiny_plotly/_serve.py +12 -5
- shiny_plotly-0.3.0/src/shiny_plotly/_update.py +107 -0
- shiny_plotly-0.3.0/src/shiny_plotly/www/shiny-plotly.js +280 -0
- shiny_plotly-0.3.0/tests/browser/apps.py +300 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/browser/conftest.py +20 -2
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/browser/test_browser.py +1 -10
- shiny_plotly-0.3.0/tests/browser/test_dark_mode.py +41 -0
- shiny_plotly-0.3.0/tests/browser/test_events.py +160 -0
- shiny_plotly-0.3.0/tests/browser/test_update.py +175 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/test_compressed_js.py +21 -1
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/test_render_plotly.py +89 -5
- shiny_plotly-0.3.0/tests/test_update.py +133 -0
- shiny_plotly-0.2.0/src/shiny_plotly/_render.py +0 -114
- shiny_plotly-0.2.0/src/shiny_plotly/www/shiny-plotly.js +0 -149
- shiny_plotly-0.2.0/tests/browser/apps.py +0 -80
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/LICENSE +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/examples/express_app.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/src/shiny_plotly/_deps.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/src/shiny_plotly/_html.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/src/shiny_plotly/py.typed +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/browser/__init__.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/newplot.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/test_fig_to_ui.py +0 -0
- {shiny_plotly-0.2.0 → shiny_plotly-0.3.0}/tests/test_plotly_js.py +0 -0
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0](https://github.com/rvben/shiny-plotly/compare/v0.2.0...v0.3.0) - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `@render_plotly(events=...)` forwards plotly events to Shiny inputs: any of `click`, `hover`, `selected` and `relayout` arrive as `input.<id>_<event>` (namespaced inside a module), carrying plotly's event data cut to what serializes (`points` with each point's scalar fields plus `customdata`, `bbox`, `pointNumbers`; `range` or `lassoPoints` for selections; relayout data as is). A click fires on every click; hover is debounced and becomes `None` when the pointer leaves; a deselect sets `selected` to `None`. Handlers attach once per graph div and survive re-renders. `max_event_points` (default 10 000) caps the points one event carries: above it the value arrives with `"points": None` and `point_count` set, its `range` or `lassoPoints` intact, because a point is about 100 bytes of JSON and a box over a dense trace would otherwise build a message of many megabytes, or one above uvicorn's default 16 MB websocket limit, which closes the session (`make bench-events` measures it; 200 000 selected points are 20.9 MB).
|
|
15
|
+
- `extend_traces(id, data, indices=None, *, max_points=None)`, `restyle(id, update, indices=None)` and `relayout(id, update)`: in-place updates to the figure an output holds, sent as Shiny custom messages and applied in the browser with `Plotly.extendTraces`, `Plotly.restyle` and `Plotly.relayout`. Values go through plotly's encoder; the id is namespaced inside a module; an update sent while the output has no figure drawn is held and applied after its next draw; a re-render replaces the figure, updates included.
|
|
16
|
+
- `enable_compressed_plotly_js(app)` is public: a Core app can serve plotly.js compressed and immutable from its very first request instead of from its first session on.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `output_plotly()` now carries the htmltools fill CSS itself. On a page that did not load it otherwise (`ui.page_fluid` without a card), a fixed-height output (`output_plotly(id, height="200px")`) with a bare `@render_plotly` drew a 400px graph that overflowed the output and whatever sat below it.
|
|
21
|
+
- `output_plotly(id)` (and the Express auto output) did not namespace its id inside a Shiny module, so a `@render_plotly` in a module never found its output.
|
|
22
|
+
- The declared dependency floor was wrong: `plotly>=5.0` and `htmltools>=0.5` could not work (`fig_to_ui` needs `to_html(div_id=...)`, which plotly 5.5 introduced; shiny 1.0 itself needs htmltools 0.5.2). The bounds are now `plotly>=5.5`, `htmltools>=0.5.2`, and CI installs exactly that floor (with shiny 1.0) and runs the whole suite against it, browser tests included (`make check-floor`).
|
|
23
|
+
|
|
10
24
|
## [0.2.0](https://github.com/rvben/shiny-plotly/compare/v0.1.0...v0.2.0) - 2026-08-19
|
|
11
25
|
|
|
12
26
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shiny-plotly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -21,8 +21,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: htmltools>=0.5
|
|
25
|
-
Requires-Dist: plotly>=5.
|
|
24
|
+
Requires-Dist: htmltools>=0.5.2
|
|
25
|
+
Requires-Dist: plotly>=5.5
|
|
26
26
|
Requires-Dist: shiny>=1.0
|
|
27
27
|
Provides-Extra: brotli
|
|
28
28
|
Requires-Dist: brotli>=1.1; extra == 'brotli'
|
|
@@ -73,7 +73,7 @@ Measured on the same app (a slider and one fillable card with a line chart; `ben
|
|
|
73
73
|
| Websocket bytes per re-render | 5.4 MB | 10 kB |
|
|
74
74
|
| Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
|
|
75
75
|
|
|
76
|
-
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (in-place `FigureWidget`
|
|
76
|
+
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (arbitrary in-place `FigureWidget` mutation, any ipywidget), which the table does not measure; the common in-place updates, appending points and changing trace or layout attributes, are covered by `extend_traces`, `restyle` and `relayout` below. `make bench` reproduces it; `bench/results.json` holds the raw numbers.
|
|
77
77
|
|
|
78
78
|
## Install
|
|
79
79
|
|
|
@@ -156,7 +156,9 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
156
156
|
width="100%",
|
|
157
157
|
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
|
|
158
158
|
config={"displaylogo": False},
|
|
159
|
-
|
|
159
|
+
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
160
|
+
max_event_points=10_000, # above it an event carries the count and range, not the points
|
|
161
|
+
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
160
162
|
)
|
|
161
163
|
def sales(): ...
|
|
162
164
|
```
|
|
@@ -165,7 +167,7 @@ def sales(): ...
|
|
|
165
167
|
|
|
166
168
|
### Re-renders, zoom and pan
|
|
167
169
|
|
|
168
|
-
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the handlers `post_script`
|
|
170
|
+
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the event handlers (from `events=` or `post_script`) and plotly's per-graph state all survive a re-render.
|
|
169
171
|
|
|
170
172
|
Whether the user's zoom and pan survive is plotly's `uirevision` rule, the same one shinywidgets users rely on for in-place updates: set `layout.uirevision` to any value and keep it the same across renders to preserve the view, change it to reset the view, leave it unset to reset on every render.
|
|
171
173
|
|
|
@@ -200,32 +202,137 @@ The rules mirror `output_widget`:
|
|
|
200
202
|
|
|
201
203
|
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.
|
|
202
204
|
|
|
203
|
-
###
|
|
205
|
+
### Dark mode
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
Plotly does not follow Bootstrap's color mode by itself. Give the dark mode switch an id, read it in the render function to pick the template, and make the figure's backgrounds transparent so the card's own background shows through in both modes:
|
|
206
208
|
|
|
207
209
|
```python
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
});
|
|
213
|
-
"""
|
|
210
|
+
app_ui = ui.page_fillable(
|
|
211
|
+
ui.input_dark_mode(id="mode"),
|
|
212
|
+
ui.card(output_plotly("sales")),
|
|
213
|
+
)
|
|
214
214
|
|
|
215
215
|
|
|
216
|
-
@render_plotly
|
|
216
|
+
@render_plotly
|
|
217
|
+
def sales():
|
|
218
|
+
template = "plotly_dark" if input.mode() == "dark" else "plotly"
|
|
219
|
+
fig = px.bar(df, x="month", y="total", template=template)
|
|
220
|
+
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
224
|
+
|
|
225
|
+
### Events back to Shiny
|
|
226
|
+
|
|
227
|
+
`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`.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
@render_plotly(events=("click", "selected"))
|
|
217
231
|
def scatter(): ...
|
|
218
232
|
|
|
219
233
|
|
|
220
234
|
@render.text
|
|
221
235
|
def click_info():
|
|
222
|
-
if not input.
|
|
236
|
+
if not input.scatter_click.is_set():
|
|
223
237
|
return "Click a point."
|
|
224
|
-
pt = input.
|
|
225
|
-
return f"x={pt['x']}, y={pt['y']}"
|
|
238
|
+
pt = input.scatter_click()["points"][0]
|
|
239
|
+
return f"trace {pt['curveNumber']}, point {pt['pointNumber']}: x={pt['x']}, y={pt['y']}"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
What arrives is plotly's own event data, cut to what serializes, the same way Dash cuts it:
|
|
243
|
+
|
|
244
|
+
| event | value of `input.<id>_<event>()` |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| `click` | `{"points": [...]}`; fires on every click, a repeated one too |
|
|
247
|
+
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
248
|
+
| `selected` | `{"points": [...], "range": {"x": [..], "y": [..]}}` for a box, `lassoPoints` for a lasso; `None` after a double-click deselect; above `max_event_points` the points give way to `point_count` (below) |
|
|
249
|
+
| `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 |
|
|
250
|
+
|
|
251
|
+
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
252
|
+
|
|
253
|
+
#### Dense traces
|
|
254
|
+
|
|
255
|
+
A point is about 100 bytes of JSON, so a box over a dense trace builds a large message, and a large enough one ends the session: uvicorn closes a websocket on a message above 16 MB by default. `max_event_points` (default 10 000) is the most points one event carries. Above it the points stay in the browser and the value says so, with the selection's geometry intact:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
@render_plotly(events="selected")
|
|
259
|
+
def scatter(): ...
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@render.text
|
|
263
|
+
def picked():
|
|
264
|
+
sel = input.scatter_selected()
|
|
265
|
+
if sel is None:
|
|
266
|
+
return "Nothing selected."
|
|
267
|
+
if sel["points"] is not None:
|
|
268
|
+
return f"{len(sel['points'])} points"
|
|
269
|
+
# More than max_event_points: {"points": None, "point_count": 120000, "range": {...}}.
|
|
270
|
+
# The data is here, so membership is a filter on the box the user dragged.
|
|
271
|
+
(x0, x1), (y0, y1) = sel["range"]["x"], sel["range"]["y"]
|
|
272
|
+
inside = df[df.x.between(x0, x1) & df.y.between(y0, y1)]
|
|
273
|
+
return f"{sel['point_count']} points, {len(inside)} rows"
|
|
226
274
|
```
|
|
227
275
|
|
|
228
|
-
`
|
|
276
|
+
The value is never silently cut: `points` is a full list or `None`, and `point_count` is there when it is `None`. A lasso carries `lassoPoints` (the polygon's `x` and `y` lists) instead of `range`. `max_event_points=None` lifts the cap. Measured with `make bench-events` (one `Scattergl` trace, every point box-selected with a real mouse, headless Chromium and the server on the same laptop, 2026-08-19):
|
|
277
|
+
|
|
278
|
+
| points | `max_event_points` | event JSON | mouse up to server |
|
|
279
|
+
| --- | --- | --- | --- |
|
|
280
|
+
| 1 000 | 10 000 | 99 kB | 83 ms |
|
|
281
|
+
| 10 000 | 10 000 | 1.01 MB | 149 ms |
|
|
282
|
+
| 100 000 | 10 000 | 136 B | 24 ms |
|
|
283
|
+
| 100 000 | none | 10.33 MB | 1017 ms |
|
|
284
|
+
| 200 000 | 10 000 | 135 B | 108 ms |
|
|
285
|
+
| 200 000 | none | 20.89 MB | disconnected |
|
|
286
|
+
|
|
287
|
+
`click` and `hover` carry one point per trace under the pointer, so the cap matters for `selected`; hover is also debounced (100 ms), so a pointer sweeping across a dense trace sends one event when it rests, not one per point.
|
|
288
|
+
|
|
289
|
+
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
LEGEND_TO_INPUT = """
|
|
293
|
+
document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
|
|
294
|
+
Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
|
|
295
|
+
return true; // let plotly toggle the trace as usual
|
|
296
|
+
});
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@render_plotly(post_script=LEGEND_TO_INPUT)
|
|
301
|
+
def scatter(): ...
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Live updates without a re-render
|
|
305
|
+
|
|
306
|
+
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `restyle` and `relayout` call the plotly.js functions of the same names on the graph an output holds. All three are coroutines, so the effect that calls them is `async def`.
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
from shiny_plotly import extend_traces, relayout, restyle
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@render_plotly
|
|
313
|
+
def prices():
|
|
314
|
+
return go.Figure(go.Scatter(x=[], y=[], mode="lines")) # the seed; the stream fills it
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@reactive.effect
|
|
318
|
+
async def _stream():
|
|
319
|
+
reactive.invalidate_later(1)
|
|
320
|
+
t, v = latest_sample()
|
|
321
|
+
await extend_traces("prices", {"x": [[t]], "y": [[v]]}, max_points=500)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@reactive.effect
|
|
325
|
+
@reactive.event(input.highlight)
|
|
326
|
+
async def _highlight():
|
|
327
|
+
await restyle("prices", {"line.color": "crimson"}, indices=0)
|
|
328
|
+
await relayout("prices", {"title.text": "highlighted"})
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- `extend_traces(id, data, indices=None, *, max_points=None)`: `data` maps an array attribute to one sequence of new values per trace, in the order of `indices` (`{"x": [[t]], "y": [[v]]}` appends one point to one trace; `{"y": [[1], [2]]}` with `indices=[0, 1]` one point to each of two). `indices` (an int or a list) defaults to every trace; `max_points` drops the oldest points past that many, for a rolling window.
|
|
332
|
+
- `restyle(id, update, indices=None)`: `update` maps attribute paths to values; `{"marker.color": "red"}` applies to every trace in `indices`, a list value applies per trace (`{"opacity": [0.5, 1]}` with `indices=[0, 1]`).
|
|
333
|
+
- `relayout(id, update)`: layout attribute paths, `{"title.text": "Live"}`, `{"xaxis.range": [0, 10]}`, `{"xaxis.autorange": True}`. With `events="relayout"` on the output, the result comes back as `input.<id>_relayout`, the same as a user's zoom.
|
|
334
|
+
|
|
335
|
+
The values go through plotly's encoder, so numpy arrays, pandas columns and datetimes work. The id is namespaced inside a module, like the output. An update reaches the figure that is drawn at that moment; one sent while the output has no figure (its first render is still running, it sits in a hidden tab, it shows an error or was emptied by `None`) is held and applied, in order, right after the output's next draw. A re-render replaces the figure, updates included, with what the render function returns: the server stays the source of truth, and a figure that should keep its streamed points across a re-render builds them in from server-side state.
|
|
229
336
|
|
|
230
337
|
### Lower level
|
|
231
338
|
|
|
@@ -233,6 +340,8 @@ def click_info():
|
|
|
233
340
|
- `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.
|
|
234
341
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
235
342
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
343
|
+
- `enable_compressed_plotly_js(app)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
|
|
344
|
+
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
236
345
|
|
|
237
346
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
238
347
|
|
|
@@ -244,7 +353,16 @@ Shiny serves HTML dependencies from a plain static mount: no compression, no `Ca
|
|
|
244
353
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
245
354
|
```
|
|
246
355
|
|
|
247
|
-
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.
|
|
356
|
+
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:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
from shiny_plotly import enable_compressed_plotly_js
|
|
360
|
+
|
|
361
|
+
app = App(app_ui, server)
|
|
362
|
+
enable_compressed_plotly_js(app)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
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.
|
|
248
366
|
|
|
249
367
|
## Examples
|
|
250
368
|
|
|
@@ -258,11 +376,12 @@ uv run --with shiny-plotly shiny run examples/express_app.py
|
|
|
258
376
|
```sh
|
|
259
377
|
make sync # uv sync --all-groups
|
|
260
378
|
make browsers # playwright install chromium, once
|
|
261
|
-
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
|
|
379
|
+
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check, floor check
|
|
262
380
|
make bench # the shinywidgets comparison above, on this machine
|
|
381
|
+
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
263
382
|
```
|
|
264
383
|
|
|
265
|
-
`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, `post_script` click wiring (once, not stacked), 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.
|
|
384
|
+
`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.
|
|
266
385
|
|
|
267
386
|
## License
|
|
268
387
|
|
|
@@ -43,7 +43,7 @@ Measured on the same app (a slider and one fillable card with a line chart; `ben
|
|
|
43
43
|
| Websocket bytes per re-render | 5.4 MB | 10 kB |
|
|
44
44
|
| Re-render round trip, median of 50 | 1.1 to 1.4 s | 11 to 14 ms |
|
|
45
45
|
|
|
46
|
-
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (in-place `FigureWidget`
|
|
46
|
+
Both need plotly.js in the browser. shiny-plotly serves `plotly.min.js` compressed (4.9 MB raw) with `Cache-Control: immutable`, so a browser fetches it once per plotly version; shinywidgets sends plotly's widget bundle as part of the `FigureWidget` state over the websocket, and a re-render creates a new `FigureWidget`, so that cost is paid on every visit and every re-render. The round-trip numbers come from a loaded laptop and are a range across runs, not a constant. shinywidgets does things this package does not (arbitrary in-place `FigureWidget` mutation, any ipywidget), which the table does not measure; the common in-place updates, appending points and changing trace or layout attributes, are covered by `extend_traces`, `restyle` and `relayout` below. `make bench` reproduces it; `bench/results.json` holds the raw numbers.
|
|
47
47
|
|
|
48
48
|
## Install
|
|
49
49
|
|
|
@@ -126,7 +126,9 @@ The decorator creates its own output placeholder in Express, just like `@render_
|
|
|
126
126
|
width="100%",
|
|
127
127
|
figurewidget_margins=True, # the l16/t32/r16/b16 margins shinywidgets applies
|
|
128
128
|
config={"displaylogo": False},
|
|
129
|
-
|
|
129
|
+
events=("click", "selected"), # arrive as input.sales_click, input.sales_selected
|
|
130
|
+
max_event_points=10_000, # above it an event carries the count and range, not the points
|
|
131
|
+
post_script=MORE_JS, # JavaScript run once, when the graph is first drawn
|
|
130
132
|
)
|
|
131
133
|
def sales(): ...
|
|
132
134
|
```
|
|
@@ -135,7 +137,7 @@ def sales(): ...
|
|
|
135
137
|
|
|
136
138
|
### Re-renders, zoom and pan
|
|
137
139
|
|
|
138
|
-
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the handlers `post_script`
|
|
140
|
+
Each `output_plotly` holds one plotly graph div. The first figure is drawn with `Plotly.newPlot`; every later one goes through `Plotly.react`, which diffs the new figure into the graph that is already there. So the DOM node, the event handlers (from `events=` or `post_script`) and plotly's per-graph state all survive a re-render.
|
|
139
141
|
|
|
140
142
|
Whether the user's zoom and pan survive is plotly's `uirevision` rule, the same one shinywidgets users rely on for in-place updates: set `layout.uirevision` to any value and keep it the same across renders to preserve the view, change it to reset the view, leave it unset to reset on every render.
|
|
141
143
|
|
|
@@ -170,32 +172,137 @@ The rules mirror `output_widget`:
|
|
|
170
172
|
|
|
171
173
|
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.
|
|
172
174
|
|
|
173
|
-
###
|
|
175
|
+
### Dark mode
|
|
174
176
|
|
|
175
|
-
|
|
177
|
+
Plotly does not follow Bootstrap's color mode by itself. Give the dark mode switch an id, read it in the render function to pick the template, and make the figure's backgrounds transparent so the card's own background shows through in both modes:
|
|
176
178
|
|
|
177
179
|
```python
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
"""
|
|
180
|
+
app_ui = ui.page_fillable(
|
|
181
|
+
ui.input_dark_mode(id="mode"),
|
|
182
|
+
ui.card(output_plotly("sales")),
|
|
183
|
+
)
|
|
184
184
|
|
|
185
185
|
|
|
186
|
-
@render_plotly
|
|
186
|
+
@render_plotly
|
|
187
|
+
def sales():
|
|
188
|
+
template = "plotly_dark" if input.mode() == "dark" else "plotly"
|
|
189
|
+
fig = px.bar(df, x="month", y="total", template=template)
|
|
190
|
+
return fig.update_layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Flipping the switch re-renders the figure through `Plotly.react`, the same as any other re-render. `input.mode()` is `"light"` or `"dark"` and follows the user's system preference when the switch is not given an initial `mode`.
|
|
194
|
+
|
|
195
|
+
### Events back to Shiny
|
|
196
|
+
|
|
197
|
+
`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`.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
@render_plotly(events=("click", "selected"))
|
|
187
201
|
def scatter(): ...
|
|
188
202
|
|
|
189
203
|
|
|
190
204
|
@render.text
|
|
191
205
|
def click_info():
|
|
192
|
-
if not input.
|
|
206
|
+
if not input.scatter_click.is_set():
|
|
193
207
|
return "Click a point."
|
|
194
|
-
pt = input.
|
|
195
|
-
return f"x={pt['x']}, y={pt['y']}"
|
|
208
|
+
pt = input.scatter_click()["points"][0]
|
|
209
|
+
return f"trace {pt['curveNumber']}, point {pt['pointNumber']}: x={pt['x']}, y={pt['y']}"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
What arrives is plotly's own event data, cut to what serializes, the same way Dash cuts it:
|
|
213
|
+
|
|
214
|
+
| event | value of `input.<id>_<event>()` |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `click` | `{"points": [...]}`; fires on every click, a repeated one too |
|
|
217
|
+
| `hover` | `{"points": [...]}` while over a point, `None` once the pointer leaves; debounced (100 ms) |
|
|
218
|
+
| `selected` | `{"points": [...], "range": {"x": [..], "y": [..]}}` for a box, `lassoPoints` for a lasso; `None` after a double-click deselect; above `max_event_points` the points give way to `point_count` (below) |
|
|
219
|
+
| `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 |
|
|
220
|
+
|
|
221
|
+
Each point carries plotly's scalar fields for that trace type (`curveNumber`, `pointNumber`, `pointIndex`, `x`, `y`, `z`, `text`, `label`, `value`, `lat`, `lon`, ...) plus `customdata` (as a plain list, also when it was a numpy array), `bbox` and `pointNumbers` when present. `input.<id>_<event>()` raises a silent exception until the event has fired once, so check `is_set()` when the output should show something before that.
|
|
222
|
+
|
|
223
|
+
#### Dense traces
|
|
224
|
+
|
|
225
|
+
A point is about 100 bytes of JSON, so a box over a dense trace builds a large message, and a large enough one ends the session: uvicorn closes a websocket on a message above 16 MB by default. `max_event_points` (default 10 000) is the most points one event carries. Above it the points stay in the browser and the value says so, with the selection's geometry intact:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
@render_plotly(events="selected")
|
|
229
|
+
def scatter(): ...
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@render.text
|
|
233
|
+
def picked():
|
|
234
|
+
sel = input.scatter_selected()
|
|
235
|
+
if sel is None:
|
|
236
|
+
return "Nothing selected."
|
|
237
|
+
if sel["points"] is not None:
|
|
238
|
+
return f"{len(sel['points'])} points"
|
|
239
|
+
# More than max_event_points: {"points": None, "point_count": 120000, "range": {...}}.
|
|
240
|
+
# The data is here, so membership is a filter on the box the user dragged.
|
|
241
|
+
(x0, x1), (y0, y1) = sel["range"]["x"], sel["range"]["y"]
|
|
242
|
+
inside = df[df.x.between(x0, x1) & df.y.between(y0, y1)]
|
|
243
|
+
return f"{sel['point_count']} points, {len(inside)} rows"
|
|
196
244
|
```
|
|
197
245
|
|
|
198
|
-
`
|
|
246
|
+
The value is never silently cut: `points` is a full list or `None`, and `point_count` is there when it is `None`. A lasso carries `lassoPoints` (the polygon's `x` and `y` lists) instead of `range`. `max_event_points=None` lifts the cap. Measured with `make bench-events` (one `Scattergl` trace, every point box-selected with a real mouse, headless Chromium and the server on the same laptop, 2026-08-19):
|
|
247
|
+
|
|
248
|
+
| points | `max_event_points` | event JSON | mouse up to server |
|
|
249
|
+
| --- | --- | --- | --- |
|
|
250
|
+
| 1 000 | 10 000 | 99 kB | 83 ms |
|
|
251
|
+
| 10 000 | 10 000 | 1.01 MB | 149 ms |
|
|
252
|
+
| 100 000 | 10 000 | 136 B | 24 ms |
|
|
253
|
+
| 100 000 | none | 10.33 MB | 1017 ms |
|
|
254
|
+
| 200 000 | 10 000 | 135 B | 108 ms |
|
|
255
|
+
| 200 000 | none | 20.89 MB | disconnected |
|
|
256
|
+
|
|
257
|
+
`click` and `hover` carry one point per trace under the pointer, so the cap matters for `selected`; hover is also debounced (100 ms), so a pointer sweeping across a dense trace sends one event when it rests, not one per point.
|
|
258
|
+
|
|
259
|
+
For anything else, `post_script` runs once, after the first figure is drawn, with `{plot_id}` replaced by the graph div's id. Re-renders go through `Plotly.react` into the same graph div, so handlers attached either way stay attached and are never stacked.
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
LEGEND_TO_INPUT = """
|
|
263
|
+
document.getElementById('{plot_id}').on('plotly_legendclick', function (ev) {
|
|
264
|
+
Shiny.setInputValue('legend', ev.curveNumber, {priority: 'event'});
|
|
265
|
+
return true; // let plotly toggle the trace as usual
|
|
266
|
+
});
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@render_plotly(post_script=LEGEND_TO_INPUT)
|
|
271
|
+
def scatter(): ...
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Live updates without a re-render
|
|
275
|
+
|
|
276
|
+
A re-render sends the whole figure. For a stream of points, a colour change or a new title, send just the change: `extend_traces`, `restyle` and `relayout` call the plotly.js functions of the same names on the graph an output holds. All three are coroutines, so the effect that calls them is `async def`.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from shiny_plotly import extend_traces, relayout, restyle
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@render_plotly
|
|
283
|
+
def prices():
|
|
284
|
+
return go.Figure(go.Scatter(x=[], y=[], mode="lines")) # the seed; the stream fills it
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@reactive.effect
|
|
288
|
+
async def _stream():
|
|
289
|
+
reactive.invalidate_later(1)
|
|
290
|
+
t, v = latest_sample()
|
|
291
|
+
await extend_traces("prices", {"x": [[t]], "y": [[v]]}, max_points=500)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@reactive.effect
|
|
295
|
+
@reactive.event(input.highlight)
|
|
296
|
+
async def _highlight():
|
|
297
|
+
await restyle("prices", {"line.color": "crimson"}, indices=0)
|
|
298
|
+
await relayout("prices", {"title.text": "highlighted"})
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- `extend_traces(id, data, indices=None, *, max_points=None)`: `data` maps an array attribute to one sequence of new values per trace, in the order of `indices` (`{"x": [[t]], "y": [[v]]}` appends one point to one trace; `{"y": [[1], [2]]}` with `indices=[0, 1]` one point to each of two). `indices` (an int or a list) defaults to every trace; `max_points` drops the oldest points past that many, for a rolling window.
|
|
302
|
+
- `restyle(id, update, indices=None)`: `update` maps attribute paths to values; `{"marker.color": "red"}` applies to every trace in `indices`, a list value applies per trace (`{"opacity": [0.5, 1]}` with `indices=[0, 1]`).
|
|
303
|
+
- `relayout(id, update)`: layout attribute paths, `{"title.text": "Live"}`, `{"xaxis.range": [0, 10]}`, `{"xaxis.autorange": True}`. With `events="relayout"` on the output, the result comes back as `input.<id>_relayout`, the same as a user's zoom.
|
|
304
|
+
|
|
305
|
+
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.
|
|
199
306
|
|
|
200
307
|
### Lower level
|
|
201
308
|
|
|
@@ -203,6 +310,8 @@ def click_info():
|
|
|
203
310
|
- `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.
|
|
204
311
|
- `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
|
|
205
312
|
- `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
|
|
313
|
+
- `enable_compressed_plotly_js(app)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
|
|
314
|
+
- `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
|
|
206
315
|
|
|
207
316
|
`render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
|
|
208
317
|
|
|
@@ -214,7 +323,16 @@ Shiny serves HTML dependencies from a plain static mount: no compression, no `Ca
|
|
|
214
323
|
uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
|
|
215
324
|
```
|
|
216
325
|
|
|
217
|
-
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.
|
|
326
|
+
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:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
from shiny_plotly import enable_compressed_plotly_js
|
|
330
|
+
|
|
331
|
+
app = App(app_ui, server)
|
|
332
|
+
enable_compressed_plotly_js(app)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
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.
|
|
218
336
|
|
|
219
337
|
## Examples
|
|
220
338
|
|
|
@@ -228,11 +346,12 @@ uv run --with shiny-plotly shiny run examples/express_app.py
|
|
|
228
346
|
```sh
|
|
229
347
|
make sync # uv sync --all-groups
|
|
230
348
|
make browsers # playwright install chromium, once
|
|
231
|
-
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check
|
|
349
|
+
make check # lint, typecheck, unit + e2e tests, browser tests, wheel check, floor check
|
|
232
350
|
make bench # the shinywidgets comparison above, on this machine
|
|
351
|
+
make bench-events # what a selection over a dense trace costs, capped and uncapped
|
|
233
352
|
```
|
|
234
353
|
|
|
235
|
-
`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, `post_script` click wiring (once, not stacked), 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.
|
|
354
|
+
`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.
|
|
236
355
|
|
|
237
356
|
## License
|
|
238
357
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Shiny Core example:
|
|
1
|
+
"""Shiny Core example: four plotly charts rendered without shinywidgets, one of them live.
|
|
2
2
|
|
|
3
3
|
Run with: uv run --with shiny-plotly shiny run examples/core_app.py
|
|
4
4
|
"""
|
|
@@ -9,15 +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 output_plotly, render_plotly
|
|
13
|
-
|
|
14
|
-
# Forwards plotly click events to a Shiny input. {plot_id} is the graph div's id.
|
|
15
|
-
CLICK_TO_INPUT = """
|
|
16
|
-
document.getElementById('{plot_id}').on('plotly_click', function (ev) {
|
|
17
|
-
var p = ev.points[0];
|
|
18
|
-
Shiny.setInputValue('clicked', {x: p.x, y: p.y}, {priority: 'event'});
|
|
19
|
-
});
|
|
20
|
-
"""
|
|
12
|
+
from shiny_plotly import enable_compressed_plotly_js, extend_traces, output_plotly, render_plotly
|
|
21
13
|
|
|
22
14
|
app_ui = ui.page_sidebar(
|
|
23
15
|
ui.sidebar(
|
|
@@ -37,9 +29,15 @@ app_ui = ui.page_sidebar(
|
|
|
37
29
|
full_screen=True,
|
|
38
30
|
),
|
|
39
31
|
),
|
|
40
|
-
ui.
|
|
41
|
-
ui.
|
|
42
|
-
|
|
32
|
+
ui.layout_columns(
|
|
33
|
+
ui.card(
|
|
34
|
+
ui.card_header("Fixed height, click a point"),
|
|
35
|
+
output_plotly("fixed_plot"),
|
|
36
|
+
),
|
|
37
|
+
ui.card(
|
|
38
|
+
ui.card_header("Live: a point a second, no re-render"),
|
|
39
|
+
output_plotly("live_plot"),
|
|
40
|
+
),
|
|
43
41
|
),
|
|
44
42
|
title="shiny-plotly",
|
|
45
43
|
fillable=True,
|
|
@@ -68,17 +66,39 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
68
66
|
x, y = data()
|
|
69
67
|
return go.Figure(go.Scatter(x=x, y=[abs(v) for v in y], fill="tozeroy"))
|
|
70
68
|
|
|
71
|
-
|
|
69
|
+
# Clicks arrive as input.fixed_plot_click (the output id plus the event name).
|
|
70
|
+
@render_plotly(height="260px", events="click", config={"displaylogo": False})
|
|
72
71
|
def fixed_plot():
|
|
73
72
|
x, y = data()
|
|
74
73
|
return go.Figure(go.Scatter(x=x, y=y, mode="markers"))
|
|
75
74
|
|
|
75
|
+
# The render function draws the seed once; every second an effect appends one point
|
|
76
|
+
# to the graph in the browser, keeping the last 60, and nothing is re-rendered.
|
|
77
|
+
@render_plotly(height="260px")
|
|
78
|
+
def live_plot():
|
|
79
|
+
return go.Figure(go.Scatter(x=[], y=[], mode="lines")).update_layout(
|
|
80
|
+
xaxis_title="tick", yaxis_title="value"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
tick = 0
|
|
84
|
+
|
|
85
|
+
@reactive.effect
|
|
86
|
+
async def _stream():
|
|
87
|
+
nonlocal tick
|
|
88
|
+
reactive.invalidate_later(1)
|
|
89
|
+
tick += 1
|
|
90
|
+
point = {"x": [[tick]], "y": [[random.gauss(0, 1)]]}
|
|
91
|
+
await extend_traces("live_plot", point, max_points=60)
|
|
92
|
+
|
|
76
93
|
@render.text
|
|
77
94
|
def click_info():
|
|
78
|
-
if not input.
|
|
95
|
+
if not input.fixed_plot_click.is_set():
|
|
79
96
|
return "Click a point in the bottom chart."
|
|
80
|
-
pt = input.
|
|
97
|
+
pt = input.fixed_plot_click()["points"][0]
|
|
81
98
|
return f"Clicked x={pt['x']}, y={pt['y']:.2f}"
|
|
82
99
|
|
|
83
100
|
|
|
84
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.3.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"
|
|
@@ -25,10 +25,13 @@ classifiers = [
|
|
|
25
25
|
"Topic :: Scientific/Engineering :: Visualization",
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
|
+
# The floor is what `make check-floor` installs and tests: plotly 5.5 is the first with
|
|
29
|
+
# to_html(div_id=...), shiny 1.0 is the oldest release the suite runs against, and
|
|
30
|
+
# htmltools 0.5.2 is what shiny 1.0 itself requires.
|
|
28
31
|
dependencies = [
|
|
29
32
|
"shiny>=1.0",
|
|
30
|
-
"plotly>=5.
|
|
31
|
-
"htmltools>=0.5",
|
|
33
|
+
"plotly>=5.5",
|
|
34
|
+
"htmltools>=0.5.2",
|
|
32
35
|
]
|
|
33
36
|
|
|
34
37
|
[project.optional-dependencies]
|
|
@@ -48,7 +51,6 @@ bench = [
|
|
|
48
51
|
]
|
|
49
52
|
dev = [
|
|
50
53
|
"pytest>=8",
|
|
51
|
-
"pytest-asyncio>=0.24",
|
|
52
54
|
"ruff>=0.6",
|
|
53
55
|
"pyright>=1.1.380",
|
|
54
56
|
"httpx2>=2.12.0",
|
|
@@ -65,8 +67,6 @@ include = ["src/shiny_plotly", "tests", "examples", "README.md", "CHANGELOG.md",
|
|
|
65
67
|
|
|
66
68
|
[tool.pytest.ini_options]
|
|
67
69
|
testpaths = ["tests"]
|
|
68
|
-
asyncio_mode = "auto"
|
|
69
|
-
asyncio_default_fixture_loop_scope = "function"
|
|
70
70
|
filterwarnings = ["error"]
|
|
71
71
|
markers = ["browser: drives the package in a real Chromium through playwright"]
|
|
72
72
|
|