From 4819b8d3eedffa27711ad01f9340eaad6a26e0ab Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 27 Oct 2026 20:28:09 -0500 Subject: [PATCH 1/8] feat: add render_drawdata() for drawdata ScatterWidget/BarWidget Thin renderer for drawdata's anywidget widgets (koaning/drawdata), which already work with render_widget() unmodified. Adds: - render_drawdata renderer with docstring documenting upstream caveats (ScatterWidget syncs one drawing batch behind, koaning/drawdata#40; BarWidget overwrites constructor data and ignores Python-side trait updates until re-render) - drawdata layout defaults: opt out of filling layouts since both widgets have intrinsic pixel sizes (and BarWidget's default 550px height clips at the generic 400px flex-basis) Verified in a real browser (Playwright): drawing, undo/reset, class selection, brush size, Python<->browser trait sync, re-render lifecycle (no leaks), hidden tabs, multi-session isolation. Spike report: docs/widget-reports/drawdata/report.md --- shinywidgets/__init__.py | 2 + shinywidgets/_render_widget.py | 88 +++++++++++++++++++ shinywidgets/_render_widget_base.py | 9 ++ ...test_render_widget_base_layout_defaults.py | 11 +++ 4 files changed, 110 insertions(+) diff --git a/shinywidgets/__init__.py b/shinywidgets/__init__.py index 32e06ae4..19b7cb64 100644 --- a/shinywidgets/__init__.py +++ b/shinywidgets/__init__.py @@ -22,6 +22,7 @@ render_altair, render_bokeh, render_bqplot, + render_drawdata, render_embedding_atlas, render_geolibre, render_holoviews, @@ -67,6 +68,7 @@ "render_altair", "render_bokeh", "render_bqplot", + "render_drawdata", "render_embedding_atlas", "render_geolibre", "render_holoviews", diff --git a/shinywidgets/_render_widget.py b/shinywidgets/_render_widget.py index 5f7bb017..b76335ac 100644 --- a/shinywidgets/_render_widget.py +++ b/shinywidgets/_render_widget.py @@ -26,6 +26,8 @@ import plotly.graph_objects as go from altair import JupyterChart from bqplot import Figure as BqplotFigure + from drawdata import BarWidget as DrawdataBarWidget + from drawdata import ScatterWidget as DrawdataScatterWidget from geolibre import Map as GeolibreMap from ipyaladin import Aladin from ipycanvas import Canvas as IpycanvasCanvas @@ -109,6 +111,13 @@ "IpycanvasCanvasT", bound="Union[IpycanvasCanvas, MultiCanvas, RoughCanvas, MultiRoughCanvas]", ) +# String bound for the same reason as PlotlyValueT (drawdata is optional). +# Both drawdata widgets are returned as-is (no coercion), so this bounds both +# the render function's value and the rendered widget. +DrawdataWidgetT = TypeVar( + "DrawdataWidgetT", + bound="Union[DrawdataScatterWidget, DrawdataBarWidget]", +) __all__ = ( "render_widget", @@ -116,6 +125,7 @@ "render_bokeh", "render_embedding_atlas", "render_bqplot", + "render_drawdata", "render_geolibre", "render_holoviews", "render_ipyaladin", @@ -203,6 +213,84 @@ def slider(): """ +class render_drawdata(render_widget_base[DrawdataWidgetT, DrawdataWidgetT]): + """ + Reactively render a [drawdata](https://github.com/koaning/drawdata) drawing + widget as a Shiny output. + + The decorated function should return a `drawdata.ScatterWidget` or + `drawdata.BarWidget` (requires ``pip install 'shinywidgets[drawdata]'``). + Both widgets keep their intrinsic pixel size (their ``width``/``height`` + traits, 800x400 and 800x550 by default), so the output hugs the widget + rather than filling its container; size the drawing surface through the + constructor's ``width``/``height``, and give a `BarWidget` room for its + full height (it renders a fixed-size SVG that does not shrink). + + The decorated function also gains two read-only attributes: `.value` and + `.widget`, both the rendered widget. Use `.widget` to programmatically + update the widget in place, or to reactively read widget state (e.g., the + drawn points via ``"data"``) with :func:`~shinywidgets.reactive_read`. + + Note + --- + **Drawn data syncs one batch behind** (upstream drawdata issue + [#40](https://github.com/koaning/drawdata/issues/40), any frontend): + `ScatterWidget`'s frontend mutates the model's ``data`` array in place, so + the change from the *first* drawing batch goes undetected and nothing + syncs until a second batch is drawn (from then on, every batch syncs in + full). The undo (pops one batch) and reset buttons sync immediately. + `BarWidget` is not affected. + + Also note that `BarWidget` overwrites any ``data`` passed to its + constructor when its frontend initializes (all bins start at 0), and its + frontend ignores later Python-side trait updates until the next re-render + (it registers no ``change:`` handlers, unlike `ScatterWidget`). + + Use :func:`~shinywidgets.output_widget` in the UI to hold the output. + + Parameters + ---------- + width + A CSS length unit (e.g., ``"400px"`` or ``"100%"``) for the output's width. + height + A CSS length unit for the output's height. When ``None`` (the default), the + output hugs the widget's intrinsic height. + fill + Whether the output should grow or shrink to fit its parent container. + Defaults to ``False`` for drawdata widgets. + fillable + Whether the output container should fill available space (scrolling its + contents on overflow). When ``None`` (the default), this is ``True`` if + ``height`` is not specified. + + Examples + -------- + ```python + from drawdata import ScatterWidget + from shiny import App, render, ui + from shinywidgets import output_widget, reactive_read, render_drawdata + + app_ui = ui.page_fluid( + output_widget("canvas"), + ui.output_text_verbatim("n_points"), + ) + + + def server(input, output, session): + @render_drawdata + def canvas(): + return ScatterWidget() + + @render.text + def n_points(): + return len(reactive_read(canvas.widget, "data")) + + + app = App(app_ui, server) + ``` + """ + + # Package specific renderers that require coercion (via as_widget()) # NOTE: the types on these classes should mirror what as_widget() does class render_altair(render_widget_base[ValueT, JupyterChart]): diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py index 03af137d..4400ccce 100644 --- a/shinywidgets/_render_widget_base.py +++ b/shinywidgets/_render_widget_base.py @@ -780,6 +780,14 @@ def vizarr_layout_defaults(widget: DOMWidget, fill: bool) -> bool: if height is not None and height != widget.trait_defaults("height"): return False return fill +def drawdata_layout_defaults(widget: DOMWidget, fill: bool) -> bool: + # drawdata's ScatterWidget/BarWidget always have an intrinsic pixel size + # (their own `width`/`height` traits, 800x400 / 800x550 by default) and + # their frontends cap (scatter, via max-width) or fix (bar, a fixed-size + # SVG with no viewBox) the drawing surface to it, so filling buys nothing. + # Worse, the generic 400px flex-basis clips BarWidget's default 550px + # height, so opt out of filling and let the output hug the widget. + return False # Per-package layout adjustments for filling layouts, keyed by widget_pkg() @@ -793,6 +801,7 @@ def vizarr_layout_defaults(widget: DOMWidget, fill: bool) -> bool: _LAYOUT_DEFAULTS: dict[str, Callable[[DOMWidget, bool], bool]] = { "plotly": _plotly_layout_defaults, "altair": _altair_layout_defaults, + "drawdata": drawdata_layout_defaults, "geolibre": geolibre_layout_defaults, "ipyvolume": ipyvolume_layout_defaults, "ipyaladin": ipyaladin_layout_defaults, diff --git a/tests/unit/test_render_widget_base_layout_defaults.py b/tests/unit/test_render_widget_base_layout_defaults.py index dbd199a6..10ca53b3 100644 --- a/tests/unit/test_render_widget_base_layout_defaults.py +++ b/tests/unit/test_render_widget_base_layout_defaults.py @@ -509,3 +509,14 @@ def test_pythreejs_explicit_size_stays_fixed_after_frontend_sync() -> None: renderer.set_state({"_width": 800, "_height": 600}) _, fill = rwb.set_layout_defaults(renderer) assert fill is False +def test_drawdata_widgets_do_not_fill() -> None: + # drawdata widgets always have an intrinsic pixel size (their own + # width/height traits), and BarWidget's default 550px height would clip at + # the generic 400px flex-basis, so they opt out of filling layouts. + pytest.importorskip("drawdata") + from drawdata import BarWidget, ScatterWidget + + for cls in (ScatterWidget, BarWidget): + w = cls() + _, fill = rwb.set_layout_defaults(w) + assert fill is False From 536521c34e654f9c76bcbcf4a31ca072f6dfb031 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 28 Oct 2026 10:54:09 -0500 Subject: [PATCH 2/8] docs: PR-ify the drawdata spike Add the drawdata extra (pyproject + test/dev groups), the README extras table row, a CHANGELOG entry, and an examples/drawdata app (draw a dataset, watch a k-NN re-fit on every stroke). --- CHANGELOG.md | 1 + README.md | 1 + examples/drawdata/README.md | 28 ++++++ examples/drawdata/app.py | 88 +++++++++++++++++++ examples/drawdata/requirements.txt | 5 ++ pyproject.toml | 4 + shinywidgets/_render_widget_base.py | 2 + ...test_render_widget_base_layout_defaults.py | 2 + 8 files changed, 131 insertions(+) create mode 100644 examples/drawdata/README.md create mode 100644 examples/drawdata/app.py create mode 100644 examples/drawdata/requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e25bcb..51cc430f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `@render_ipycytoscape` for network graphs with [ipycytoscape](https://github.com/cytoscape/ipycytoscape). Return a `networkx` graph (or a `CytoscapeWidget`) and it fills its container, or follows an explicit `height`. Register `CytoscapeWidget.on()` callbacks in the render function to feed node clicks into a `reactive.value`. (#278) * `@render_quak` for [quak](https://github.com/manzt/quak) data tables, built from any DataFrame-like object (pandas, polars, pyarrow, etc.). The table fills its container (or respects an explicit height), and `reactive_read()` gives you the table's current sort and filter state as a SQL query. (#278, #286) * `@render_geolibre` for [GeoLibre](https://geolibre.app) maps (`pip install 'shinywidgets[geolibre]'`). You get the full GeoLibre GIS workbench as a Shiny output: project mutations (`add_geojson()`, `set_center()`, layer styling) update the live map in place, and `on_click()` callbacks plus `reactive_read(widget, "project")` bring browser interactions back to Python. The map fills its container unless given an explicit `height`, and it works deployed (shinyapps.io, Posit Connect), including layers read from files on the server's disk. geolibre's blocking command methods (e.g., `fly_to()`, `get_center()`) can't run on Shiny's event-loop thread, but they work from a worker thread (e.g., inside `reactive.extended_task`). (#341) + * `@render_drawdata` for [drawdata](https://github.com/koaning/drawdata) `ScatterWidget`/`BarWidget` drawing canvases (`pip install 'shinywidgets[drawdata]'`). Users draw a dataset with the brush (or sculpt histogram bars) and Python sees every batch via `reactive_read(widget, "data")` -- great for teach-a-model-by-drawing apps (see `examples/drawdata`, which re-fits a k-NN on every stroke). Both widgets keep their intrinsic pixel size, so the output hugs the widget rather than filling its container (this also fixes BarWidget's default 550px height being clipped at the generic 400px flex-basis); size the surface through the constructor's `width`/`height`. One upstream caveat: `ScatterWidget` syncs drawn data one batch behind (the first stroke reaches Python only when a second is drawn; undo/reset sync immediately) -- that's drawdata [issue #40](https://github.com/koaning/drawdata/issues/40), which affects Jupyter too, not a shinywidgets bug. (#278) * `@render_obsplot` for [Observable Plot](https://observablehq.com/plot/) charts built with [pyobsplot](https://juba.github.io/pyobsplot/) (`pip install 'shinywidgets[obsplot]'`). Return the widget from `Plot.plot()`; pyobsplot has no browser-to-Python sync, so there's nothing for `reactive_read()`, but charts update in place via `.widget.spec = {...}`. Charts shrink to fit their container but never grow past the spec width (default 640px), and filtered pandas DataFrames need `df.reset_index(drop=True)` to serialize with pyobsplot 0.5.4. (#359) * `@render_vizarr` for [vizarr](https://github.com/hms-dbmi/vizarr) viewers of OME-NGFF (zarr) bioimages. Local zarr stores are served chunk-by-chunk over the widget comm (or pass a URL string and the browser fetches it directly), the camera syncs both ways via the `view_state` trait (`reactive_read()` it, or set it to fly to a region), and `add_image()` can be called in place to add layers without a re-render. The viewer fills filling layouts, respects an explicit `output_widget(height=...)`, and honors its own `height` trait otherwise. Requires an internet connection (the viewer's JavaScript loads from a CDN). (#358) * Bokeh plots using [extension models](https://docs.bokeh.org/en/latest/docs/user_guide/advanced/extensions.html) now work out of the box -- their assets are served automatically. (#88, #256) diff --git a/README.md b/README.md index c2584107..b2a84f1b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ everything it needs: | `@render_bokeh` | `pip install 'shinywidgets[bokeh]'` | | `@render_embedding_atlas` | `pip install 'shinywidgets[embedding_atlas]'` (Python 3.11+) | | `@render_bqplot` | `pip install 'shinywidgets[bqplot]'` | +| `@render_drawdata` | `pip install 'shinywidgets[drawdata]'` | | `@render_geolibre` | `pip install 'shinywidgets[geolibre]'` (Python ≥ 3.11) | | `@render_holoviews` | `pip install 'shinywidgets[holoviews]'` | | `@render_ipyaladin` | `pip install 'shinywidgets[ipyaladin]'` | diff --git a/examples/drawdata/README.md b/examples/drawdata/README.md new file mode 100644 index 00000000..4d3fba2c --- /dev/null +++ b/examples/drawdata/README.md @@ -0,0 +1,28 @@ +# drawdata example + +Run with: + +```sh +pip install -r requirements.txt +shiny run app.py +``` + +What `@render_drawdata` offers over a static plot: the +[drawdata](https://github.com/koaning/drawdata) canvas becomes a first-class +Shiny input. Users draw a dataset with the brush (up to 4 classes on +`ScatterWidget`, or drag histogram bars on `BarWidget`), and Python sees every +batch through `reactive_read(widget, "data")` -- this app re-fits a k-NN +classifier on your drawing and re-renders the decision boundary live. + +Two things to know about: + +- `ScatterWidget` syncs drawn data to Python **one batch behind**: the first + stroke only reaches Python when a second is drawn (upstream drawdata + [issue #40](https://github.com/koaning/drawdata/issues/40), which affects + Jupyter too). The undo and reset buttons sync immediately. +- Both widgets have an intrinsic pixel size, so the output hugs the widget + instead of filling its container. Size the drawing surface through the + constructor (`ScatterWidget(width=1000, height=500)`); `BarWidget` renders + a fixed-size SVG that doesn't shrink, so give it room (and note it + overwrites constructor-provided `data` at first render and ignores later + Python-side trait updates until the next re-render). diff --git a/examples/drawdata/app.py b/examples/drawdata/app.py new file mode 100644 index 00000000..4ee9c80c --- /dev/null +++ b/examples/drawdata/app.py @@ -0,0 +1,88 @@ +# Draw a dataset, watch Python learn it. +# +# Draw points of two (or more) classes on the canvas with the brush; a k-NN +# classifier trained on your drawing is re-fit on every stroke and its +# decision boundary is rendered beside the canvas. +# +# Note: the widget only syncs a drawing batch to Python once the *next* batch +# starts (upstream drawdata issue #40), so the boundary updates one stroke +# behind your drawing. Undo and reset sync immediately. + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from drawdata import ScatterWidget +from shiny import render +from shiny.express import ui + +from shinywidgets import reactive_read, render_drawdata + +W, H = 800, 400 # the widget's coordinate space (y points up in the data) + + +def drawn() -> pd.DataFrame: + # NOTE: a plain `canvas.widget.data` (or `.data_as_pandas`) read is not + # reactive -- always go through reactive_read() to re-run on new strokes. + return pd.DataFrame(reactive_read(canvas.widget, "data")) + + +with ui.layout_columns(col_widths=(7, 5)): + with ui.card(): + ui.card_header("Draw a dataset (pick a class, then drag the brush)") + + @render_drawdata + def canvas(): + return ScatterWidget(width=W, height=H, n_classes=2) + + with ui.card(): + ui.card_header("Python learns your drawing (k-NN, k=5)") + + @render.plot + def boundary(): + df = drawn() + fig, ax = plt.subplots(figsize=(5, 4.6)) + ax.set_xlim(0, W) + ax.set_ylim(0, H) + if df.empty or df["label"].nunique() < 2: + ax.text( + W / 2, + H / 2, + "draw points of both classes", + ha="center", + va="center", + color="grey", + ) + return fig + X = df[["x", "y"]].to_numpy() + y = df["label"].to_numpy() + classes, y_idx = np.unique(y, return_inverse=True) + # k-NN decision boundary over a coarse grid + gx, gy = np.meshgrid(np.linspace(0, W, 100), np.linspace(0, H, 50)) + grid = np.c_[gx.ravel(), gy.ravel()] + d2 = ((grid[:, None, :] - X[None, :, :]) ** 2).sum(-1) + knn = np.argsort(d2, axis=1)[:, :5] + votes = np.apply_along_axis( + lambda r: np.bincount(r).argmax(), 1, y_idx[knn] + ) + ax.contourf(gx, gy, votes.reshape(gx.shape), levels=len(classes), alpha=0.25) + for c, name in zip(["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"], classes): + pts = X[y == name] + ax.scatter(pts[:, 0], pts[:, 1], c=c, s=12, label=name) + ax.legend(loc="upper right") + return fig + + +@render.code +def summary(): + df = drawn() + if df.empty: + return "No data yet — draw something!" + return ( + f"{len(df)} points drawn\n" + + df.groupby("label").size().to_string() + + "\n\nHead:\n" + + df.head(3).to_string(index=False) + ) diff --git a/examples/drawdata/requirements.txt b/examples/drawdata/requirements.txt new file mode 100644 index 00000000..c5869bfa --- /dev/null +++ b/examples/drawdata/requirements.txt @@ -0,0 +1,5 @@ +shiny +shinywidgets[drawdata] +matplotlib +numpy +pandas diff --git a/pyproject.toml b/pyproject.toml index fd5d91c7..13f8ac4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ bokeh = ["bokeh", "jupyter_bokeh>=4.1"] # embedding-atlas requires Python 3.11+, so it's not installable on 3.10 embedding_atlas = ["embedding-atlas>=0.26; python_version >= '3.11'"] bqplot = ["bqplot"] +drawdata = ["drawdata>=0.5"] # geolibre requires Python 3.11+; >=3.5.2 for blocking command methods # (fly_to(), get_center(), ...) from a worker thread outside Jupyter geolibre = ["geolibre>=3.5.2; python_version >= '3.11'"] @@ -101,6 +102,7 @@ test = [ # geoviews supports cartopy>=0.26 (which has 3.14 wheels). "cartopy<0.26; python_version < '3.14'", "datashader", + "drawdata>=0.5", "embedding-atlas>=0.26; python_version >= '3.11'", "geolibre; python_version >= '3.11'", "geoviews; python_version < '3.14'", @@ -156,6 +158,7 @@ dev = [ # geoviews supports cartopy>=0.26 (which has 3.14 wheels). "cartopy<0.26; python_version < '3.14'", "datashader", + "drawdata>=0.5", "embedding-atlas>=0.26; python_version >= '3.11'", "geolibre; python_version >= '3.11'", "geoviews; python_version < '3.14'", @@ -214,6 +217,7 @@ test = [ # geoviews supports cartopy>=0.26 (which has 3.14 wheels). "cartopy<0.26; python_version < '3.14'", "datashader", + "drawdata>=0.5", "embedding-atlas>=0.26; python_version >= '3.11'", "geolibre; python_version >= '3.11'", "geoviews; python_version < '3.14'", diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py index 4400ccce..1d24cee6 100644 --- a/shinywidgets/_render_widget_base.py +++ b/shinywidgets/_render_widget_base.py @@ -780,6 +780,8 @@ def vizarr_layout_defaults(widget: DOMWidget, fill: bool) -> bool: if height is not None and height != widget.trait_defaults("height"): return False return fill + + def drawdata_layout_defaults(widget: DOMWidget, fill: bool) -> bool: # drawdata's ScatterWidget/BarWidget always have an intrinsic pixel size # (their own `width`/`height` traits, 800x400 / 800x550 by default) and diff --git a/tests/unit/test_render_widget_base_layout_defaults.py b/tests/unit/test_render_widget_base_layout_defaults.py index 10ca53b3..5f93a694 100644 --- a/tests/unit/test_render_widget_base_layout_defaults.py +++ b/tests/unit/test_render_widget_base_layout_defaults.py @@ -509,6 +509,8 @@ def test_pythreejs_explicit_size_stays_fixed_after_frontend_sync() -> None: renderer.set_state({"_width": 800, "_height": 600}) _, fill = rwb.set_layout_defaults(renderer) assert fill is False + + def test_drawdata_widgets_do_not_fill() -> None: # drawdata widgets always have an intrinsic pixel size (their own # width/height traits), and BarWidget's default 550px height would clip at From 35bab297619e11ef670dde06367c8994d23b4a8b Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 28 Oct 2026 11:12:30 -0500 Subject: [PATCH 3/8] feat: make drawdata widgets fill their container Upstream, drawdata caps each widget's display at its width/height traits: ScatterWidget's svg gets an inline max-width (and .dd-scatter-container a CSS max-width of 1200px), and BarWidget's chart svg has fixed width/height attributes with no viewBox (and is clipped by the generic 400px flex-basis). Since those traits really define the *data coordinate space* (drawn points are stored in viewBox coordinates), scale the display losslessly around an unchanged viewBox instead: a JS-side behavior (js/src/fill.ts) removes the inline/CSS width caps and injects a viewBox + width:100%/aspect-ratio into BarWidget's svg, so both widgets fill their container's width while preserving aspect ratio. d3 resolves pointer coordinates through the svg's CTM, so brushing/dragging still works while scaled (verified in Chromium: circles land under the cursor at 1.06x and bar drags at 0.5x, both sync to Python, and the behavior re-applies after a re-render). The Python-side fill=False opt-out is removed: the widgets now keep the generic fill decision. --- CHANGELOG.md | 2 +- examples/drawdata/README.md | 12 +++-- js/src/fill.ts | 52 +++++++++++++++++++ shinywidgets/_render_widget.py | 17 +++--- shinywidgets/_render_widget_base.py | 11 ---- shinywidgets/static/output.js | 2 +- ...test_render_widget_base_layout_defaults.py | 12 +++-- 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cc430f..fbe2c0a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `@render_ipycytoscape` for network graphs with [ipycytoscape](https://github.com/cytoscape/ipycytoscape). Return a `networkx` graph (or a `CytoscapeWidget`) and it fills its container, or follows an explicit `height`. Register `CytoscapeWidget.on()` callbacks in the render function to feed node clicks into a `reactive.value`. (#278) * `@render_quak` for [quak](https://github.com/manzt/quak) data tables, built from any DataFrame-like object (pandas, polars, pyarrow, etc.). The table fills its container (or respects an explicit height), and `reactive_read()` gives you the table's current sort and filter state as a SQL query. (#278, #286) * `@render_geolibre` for [GeoLibre](https://geolibre.app) maps (`pip install 'shinywidgets[geolibre]'`). You get the full GeoLibre GIS workbench as a Shiny output: project mutations (`add_geojson()`, `set_center()`, layer styling) update the live map in place, and `on_click()` callbacks plus `reactive_read(widget, "project")` bring browser interactions back to Python. The map fills its container unless given an explicit `height`, and it works deployed (shinyapps.io, Posit Connect), including layers read from files on the server's disk. geolibre's blocking command methods (e.g., `fly_to()`, `get_center()`) can't run on Shiny's event-loop thread, but they work from a worker thread (e.g., inside `reactive.extended_task`). (#341) - * `@render_drawdata` for [drawdata](https://github.com/koaning/drawdata) `ScatterWidget`/`BarWidget` drawing canvases (`pip install 'shinywidgets[drawdata]'`). Users draw a dataset with the brush (or sculpt histogram bars) and Python sees every batch via `reactive_read(widget, "data")` -- great for teach-a-model-by-drawing apps (see `examples/drawdata`, which re-fits a k-NN on every stroke). Both widgets keep their intrinsic pixel size, so the output hugs the widget rather than filling its container (this also fixes BarWidget's default 550px height being clipped at the generic 400px flex-basis); size the surface through the constructor's `width`/`height`. One upstream caveat: `ScatterWidget` syncs drawn data one batch behind (the first stroke reaches Python only when a second is drawn; undo/reset sync immediately) -- that's drawdata [issue #40](https://github.com/koaning/drawdata/issues/40), which affects Jupyter too, not a shinywidgets bug. (#278) + * `@render_drawdata` for [drawdata](https://github.com/koaning/drawdata) `ScatterWidget`/`BarWidget` drawing canvases (`pip install 'shinywidgets[drawdata]'`). Users draw a dataset with the brush (or sculpt histogram bars) and Python sees every batch via `reactive_read(widget, "data")` -- great for teach-a-model-by-drawing apps (see `examples/drawdata`, which re-fits a k-NN on every stroke). Upstream caps each widget's display at its `width`/`height` traits (and BarWidget's fixed-size SVG is clipped by the generic 400px flex-basis), so shinywidgets lifts those caps in the browser: both widgets fill their container's width while preserving aspect ratio, and the constructor's `width`/`height` define the drawing's data coordinate space (and aspect), not its display size. One upstream caveat: `ScatterWidget` syncs drawn data one batch behind (the first stroke reaches Python only when a second is drawn; undo/reset sync immediately) -- that's drawdata [issue #40](https://github.com/koaning/drawdata/issues/40), which affects Jupyter too, not a shinywidgets bug. (#278) * `@render_obsplot` for [Observable Plot](https://observablehq.com/plot/) charts built with [pyobsplot](https://juba.github.io/pyobsplot/) (`pip install 'shinywidgets[obsplot]'`). Return the widget from `Plot.plot()`; pyobsplot has no browser-to-Python sync, so there's nothing for `reactive_read()`, but charts update in place via `.widget.spec = {...}`. Charts shrink to fit their container but never grow past the spec width (default 640px), and filtered pandas DataFrames need `df.reset_index(drop=True)` to serialize with pyobsplot 0.5.4. (#359) * `@render_vizarr` for [vizarr](https://github.com/hms-dbmi/vizarr) viewers of OME-NGFF (zarr) bioimages. Local zarr stores are served chunk-by-chunk over the widget comm (or pass a URL string and the browser fetches it directly), the camera syncs both ways via the `view_state` trait (`reactive_read()` it, or set it to fly to a region), and `add_image()` can be called in place to add layers without a re-render. The viewer fills filling layouts, respects an explicit `output_widget(height=...)`, and honors its own `height` trait otherwise. Requires an internet connection (the viewer's JavaScript loads from a CDN). (#358) * Bokeh plots using [extension models](https://docs.bokeh.org/en/latest/docs/user_guide/advanced/extensions.html) now work out of the box -- their assets are served automatically. (#88, #256) diff --git a/examples/drawdata/README.md b/examples/drawdata/README.md index 4d3fba2c..86a9bffe 100644 --- a/examples/drawdata/README.md +++ b/examples/drawdata/README.md @@ -20,9 +20,11 @@ Two things to know about: stroke only reaches Python when a second is drawn (upstream drawdata [issue #40](https://github.com/koaning/drawdata/issues/40), which affects Jupyter too). The undo and reset buttons sync immediately. -- Both widgets have an intrinsic pixel size, so the output hugs the widget - instead of filling its container. Size the drawing surface through the - constructor (`ScatterWidget(width=1000, height=500)`); `BarWidget` renders - a fixed-size SVG that doesn't shrink, so give it room (and note it +- Both widgets fill their container's width while preserving aspect ratio + (upstream caps the display at the `width`/`height` traits; shinywidgets + lifts that cap). The constructor's `width`/`height` + (`ScatterWidget(width=1000, height=500)`) define the drawing's *data + coordinate space* -- drawn points come back in those coordinates, y-up -- + and the aspect ratio, not the display size. Also note that `BarWidget` overwrites constructor-provided `data` at first render and ignores later - Python-side trait updates until the next re-render). + Python-side trait updates until the next re-render. diff --git a/js/src/fill.ts b/js/src/fill.ts index d3e73e43..131015ed 100644 --- a/js/src/fill.ts +++ b/js/src/fill.ts @@ -274,7 +274,59 @@ export const geolibreBehavior: WidgetBehavior = { }, }; +// drawdata's widgets have an intrinsic pixel size (their width/height +// traits, 800x400 / 800x550 by default) that their frontends also cap the +// *display* to: ScatterWidget's gets an inline `max-width: {width}px` +// (and .dd-scatter-container a CSS `max-width: 1200px`), and BarWidget's +// chart gets fixed width/height attributes with no viewBox (and +// .container a CSS `max-width: 800px`). The traits really define the *data +// coordinate space* (drawn points are stored in viewBox coordinates), so +// scaling the display around an unchanged viewBox is lossless (d3 resolves +// pointer coordinates through the svg's CTM, so brushing/dragging still +// works when scaled). Remove those caps so the widgets fill their +// container's width, with aspect-ratio preserving the coordinate aspect. +function relaxDrawdataSize({ lmWidget }: BehaviorContext): void { + // Lift the containers' CSS width caps (and BarWidget's 20px vertical + // margins, which just add slack in a sized output) + for (const container of lmWidget.querySelectorAll( + '.dd-scatter-container, .container' + )) { + container.style.maxWidth = 'none'; + container.style.marginTop = '0'; + container.style.marginBottom = '0'; + } + for (const svg of lmWidget.querySelectorAll('svg')) { + if (svg.style.maxWidth.endsWith('px')) { + // ScatterWidget's chart svg: inline `max-width: {width}px` is the only + // thing stopping its width:100%/aspect-ratio sizing from growing past + // the trait width + svg.style.maxWidth = 'none'; + continue; + } + // BarWidget's chart svg: fixed pixel width/height attributes, no + // viewBox (button icons are excluded -- they already have a viewBox). + // Give it one and let CSS scale it like ScatterWidget's. + const w = Number(svg.getAttribute('width')); + const h = Number(svg.getAttribute('height')); + if (svg.getAttribute('viewBox') === null && w > 0 && h > 0) { + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + svg.style.width = '100%'; + svg.style.height = 'auto'; + svg.style.aspectRatio = `${w} / ${h}`; + } + } +} + +const drawdataBehavior: WidgetBehavior = { + setupFill: (ctx) => relaxDrawdataSize(ctx), + // Also relax in fixed-size outputs: BarWidget's fixed 800px width + // otherwise overflows narrow containers, and full-width rendering matches + // how other outputs (e.g., plotly) look in plain page flow. + setupFixedSize: (ctx) => relaxDrawdataSize(ctx), +}; + export const WIDGET_BEHAVIORS: Record = { + drawdata: drawdataBehavior, ipympl: ipymplBehavior, ipyvolume: ipyvolumeBehavior, jscatter: jscatterBehavior, diff --git a/shinywidgets/_render_widget.py b/shinywidgets/_render_widget.py index b76335ac..d648d956 100644 --- a/shinywidgets/_render_widget.py +++ b/shinywidgets/_render_widget.py @@ -220,11 +220,13 @@ class render_drawdata(render_widget_base[DrawdataWidgetT, DrawdataWidgetT]): The decorated function should return a `drawdata.ScatterWidget` or `drawdata.BarWidget` (requires ``pip install 'shinywidgets[drawdata]'``). - Both widgets keep their intrinsic pixel size (their ``width``/``height`` - traits, 800x400 and 800x550 by default), so the output hugs the widget - rather than filling its container; size the drawing surface through the - constructor's ``width``/``height``, and give a `BarWidget` room for its - full height (it renders a fixed-size SVG that does not shrink). + The widget fills the output's width while preserving its aspect ratio + (upstream, both widgets cap their display at their ``width``/``height`` + traits; shinywidgets lifts that cap so they scale like other outputs). + The constructor's ``width``/``height`` define the drawing's *data + coordinate space* -- drawn points are stored in those coordinates, with + ``y`` pointing up -- and the aspect ratio, not the display size, so + choose them for the coordinate range and shape you want, not for pixels. The decorated function also gains two read-only attributes: `.value` and `.widget`, both the rendered widget. Use `.widget` to programmatically @@ -253,11 +255,10 @@ class render_drawdata(render_widget_base[DrawdataWidgetT, DrawdataWidgetT]): width A CSS length unit (e.g., ``"400px"`` or ``"100%"``) for the output's width. height - A CSS length unit for the output's height. When ``None`` (the default), the - output hugs the widget's intrinsic height. + A CSS length unit for the output's height. When ``None`` (the default), + the height follows from the filled width and the widget's aspect ratio. fill Whether the output should grow or shrink to fit its parent container. - Defaults to ``False`` for drawdata widgets. fillable Whether the output container should fill available space (scrolling its contents on overflow). When ``None`` (the default), this is ``True`` if diff --git a/shinywidgets/_render_widget_base.py b/shinywidgets/_render_widget_base.py index 1d24cee6..03af137d 100644 --- a/shinywidgets/_render_widget_base.py +++ b/shinywidgets/_render_widget_base.py @@ -782,16 +782,6 @@ def vizarr_layout_defaults(widget: DOMWidget, fill: bool) -> bool: return fill -def drawdata_layout_defaults(widget: DOMWidget, fill: bool) -> bool: - # drawdata's ScatterWidget/BarWidget always have an intrinsic pixel size - # (their own `width`/`height` traits, 800x400 / 800x550 by default) and - # their frontends cap (scatter, via max-width) or fix (bar, a fixed-size - # SVG with no viewBox) the drawing surface to it, so filling buys nothing. - # Worse, the generic 400px flex-basis clips BarWidget's default 550px - # height, so opt out of filling and let the output hug the widget. - return False - - # Per-package layout adjustments for filling layouts, keyed by widget_pkg() # (the same key used by AS_WIDGET_MAP in _as_widget.py and WIDGET_BEHAVIORS # in js/src/fill.ts). A handler receives the widget and the current fill @@ -803,7 +793,6 @@ def drawdata_layout_defaults(widget: DOMWidget, fill: bool) -> bool: _LAYOUT_DEFAULTS: dict[str, Callable[[DOMWidget, bool], bool]] = { "plotly": _plotly_layout_defaults, "altair": _altair_layout_defaults, - "drawdata": drawdata_layout_defaults, "geolibre": geolibre_layout_defaults, "ipyvolume": ipyvolume_layout_defaults, "ipyaladin": ipyaladin_layout_defaults, diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index 002bad7d..0098dbcc 100644 --- a/shinywidgets/static/output.js +++ b/shinywidgets/static/output.js @@ -36,7 +36,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \*********************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"WIDGET_BEHAVIORS\": () => (/* binding */ WIDGET_BEHAVIORS),\n/* harmony export */ \"geolibreBehavior\": () => (/* binding */ geolibreBehavior),\n/* harmony export */ \"ipyleafletBehavior\": () => (/* binding */ ipyleafletBehavior)\n/* harmony export */ });\n/* harmony import */ var _ipyvolume__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ipyvolume */ \"./src/ipyvolume.ts\");\n/* harmony import */ var _jscatter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./jscatter */ \"./src/jscatter.ts\");\n/* harmony import */ var _keplergl__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./keplergl */ \"./src/keplergl.ts\");\n/* harmony import */ var _mpl__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./mpl */ \"./src/mpl.ts\");\n/* harmony import */ var _plotly__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./plotly */ \"./src/plotly.ts\");\n/* harmony import */ var _quak__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./quak */ \"./src/quak.ts\");\n\n\n\n\n\n\n// ipympl figures don't fit themselves to their container, so drive the\n// figure size from the container's size (i.e., make fill actually work)\nconst ipymplBehavior = {\n setupFill: ({ el, lmWidget, model }) => (0,_mpl__WEBPACK_IMPORTED_MODULE_3__.setupMplAutoResize)(el, lmWidget, model),\n};\n// jupyter-scatter plots have a fixed pixel height, so drive it from the\n// container's size (i.e., make fill and an explicit output height actually work)\nconst jscatterBehavior = {\n setupFill: ({ el, lmWidget, model }) => (0,_jscatter__WEBPACK_IMPORTED_MODULE_1__.setupJscatterAutoHeight)(el, lmWidget, model),\n setupFixedSize: ({ el, lmWidget, model }) => (0,_jscatter__WEBPACK_IMPORTED_MODULE_1__.setupJscatterFixedHeight)(el, lmWidget, model),\n};\n// Plotly FigureWidget may first render at its internal 360px fallback, then\n// resize after paint. The output is already hidden at this point (every\n// render starts hidden, see renderValue()); keep it that way until a direct\n// Plotly resize completes, so the first visible paint is already settled.\nconst plotlyBehavior = {\n onDisplayed: (ctx) => {\n if (ctx.isStale())\n return;\n const plotEl = (0,_plotly__WEBPACK_IMPORTED_MODULE_4__.findPlotlyGraphDiv)(ctx.lmWidget);\n if (!plotEl) {\n ctx.dispatchResize();\n ctx.reveal();\n return;\n }\n void (0,_plotly__WEBPACK_IMPORTED_MODULE_4__.waitForPlotlyReadyToReveal)(plotEl, ctx.dispatchResize).finally(ctx.reveal);\n },\n};\n// ipyvolume figures render at a fixed pixel size and need their canvas\n// resized to the container (see setupIpyvolumeAutoResize). The same applies\n// when the output (or the widget's own Layout) has an explicit height;\n// otherwise the figure's intrinsic size applies, so leave it alone.\nconst ipyvolumeBehavior = {\n setupFill: ({ el, view, model }) => (0,_ipyvolume__WEBPACK_IMPORTED_MODULE_0__.setupIpyvolumeAutoResize)(el, view, model),\n setupFixedSize: ({ el, lmWidget, view, model, widgetWantsFill }) => \n // Only resize when the output (or the widget's own Layout, which lands on\n // the widget element) has an explicit height; otherwise the figure's\n // intrinsic pixel size applies. Checked at resize time: the Layout style\n // may only be applied after setup runs.\n //\n // A Layout height on the widget is always a size to drive the canvas to,\n // but the output's CSS height only applies when the widget itself hasn't\n // opted out of filling (widgetWantsFill) -- an explicit figure size\n // (e.g., ipv.Figure(width=400, height=300)) keeps its pixel size even\n // inside an explicitly sized output.\n (0,_ipyvolume__WEBPACK_IMPORTED_MODULE_0__.setupIpyvolumeAutoResize)(el, view, model, () => !!((widgetWantsFill && (el.style.height || lmWidget.style.height)) ||\n view.el.style.height)),\n};\nconst quakBehavior = {\n setupFill: ({ el, lmWidget }) => (0,_quak__WEBPACK_IMPORTED_MODULE_5__.setupQuakFit)(el, lmWidget, true),\n setupFixedSize: ({ el, lmWidget }) => (0,_quak__WEBPACK_IMPORTED_MODULE_5__.setupQuakFit)(el, lmWidget, false),\n};\n// Aspect-management ownership per camera, persisted across view lifetimes\n// (a cached Renderer gets a fresh view, and a fresh setupPythreejsResize()\n// closure, on every render): the aspect value we last set on each camera.\n// Without it, a re-render's new view would adopt a user-set aspect as the\n// managed baseline and overwrite it on the first resize.\nconst managedCameraAspects = new WeakMap();\n// A pythreejs Renderer draws into a canvas fixed to its _width/_height pixel\n// traits (and three.js doesn't update the camera's aspect ratio when the\n// canvas resizes), so drive both from the container's size. The traits are\n// synced, so the Python-side Renderer/camera see the new values too.\nfunction setupPythreejsResize({ el, lmWidget, model }) {\n // An inline sits on the text baseline, leaving a ~6px strut of\n // line height below it. In a container whose height is content-derived\n // (plain page_fluid, an auto-height card), that strut makes el taller than\n // the canvas, so sizing the canvas to el grows el, which fires the observer\n // again -- an unbounded feedback loop. Block display removes the strut, so\n // sizing the canvas to el is a fixed point.\n const canvas = lmWidget.querySelector('canvas');\n if (canvas)\n canvas.style.display = 'block';\n // Python can replace the camera in place (renderer.camera = ...), so\n // (re)capture the active camera on change:camera rather than only here.\n let camera;\n // Manage the aspect ratio only while it holds a value we set (or the\n // initial one): a user-set aspect (from either side) wins.\n let lastAspect;\n let lastWidth = -1;\n let lastHeight = -1;\n const applySize = (w, h) => {\n model.set('_width', w);\n model.set('_height', h);\n model.save_changes();\n if (camera && lastAspect !== undefined && camera.get('aspect') === lastAspect) {\n lastAspect = w / h;\n camera.set('aspect', lastAspect);\n camera.save_changes();\n managedCameraAspects.set(camera, lastAspect);\n }\n };\n const trackCamera = () => {\n camera = model.get('camera');\n // A camera we've managed before keeps the aspect we last set as the\n // baseline, so a user-set aspect (from either side) still wins after a\n // re-render; a never-managed camera adopts its current (initial) aspect.\n const managed = camera ? managedCameraAspects.get(camera) : undefined;\n if (managed !== undefined) {\n lastAspect = managed;\n }\n else {\n const aspect = camera === null || camera === void 0 ? void 0 : camera.get('aspect');\n lastAspect = typeof aspect === 'number' ? aspect : undefined;\n }\n // A replacement camera still holds its own (stale) aspect, so apply the\n // current dimensions immediately. resizeToFit()'s dimension cache would\n // suppress this when the container size hasn't changed.\n if (lastWidth > 0 && lastHeight > 0)\n applySize(lastWidth, lastHeight);\n };\n const resizeToFit = () => {\n const w = el.clientWidth;\n const h = el.clientHeight;\n if (w <= 0 || h <= 0 || (w === lastWidth && h === lastHeight))\n return;\n lastWidth = w;\n lastHeight = h;\n applySize(w, h);\n };\n trackCamera();\n model.on('change:camera', trackCamera);\n const observer = new ResizeObserver(() => resizeToFit());\n // Observe the output container (not the widget): with the strut removed\n // above, the container tracks the canvas exactly, so resizing the canvas\n // to it doesn't change it and the observer settles.\n observer.observe(el);\n resizeToFit();\n return () => {\n model.off('change:camera', trackCamera);\n observer.disconnect();\n };\n}\nconst pythreejsBehavior = {\n setupFill: (ctx) => setupPythreejsResize(ctx),\n // A fixed-size output with an explicit height (e.g.,\n // output_widget(height=\"250px\", fillable=False)) gets the canvas sized to\n // it too, unless the Renderer opted out of filling (its own explicit\n // width/height), in which case its pixel size is respected. An unsized\n // non-fillable output keeps the widget's own size rather than stretching\n // the canvas to the output's full width.\n setupFixedSize: (ctx) => ctx.widgetWantsFill && ctx.el.style.height ? setupPythreejsResize(ctx) : undefined,\n};\n// kepler.gl's size comes from a pixel `height` trait, so it needs to be driven\n// from the container whenever the container's height is defined by the app\n// (a filling layout, or an explicit output_widget(height=)).\nconst keplerglBehavior = {\n setupFill: ({ el, model }) => (0,_keplergl__WEBPACK_IMPORTED_MODULE_2__.setupKeplerAutoHeight)(el, model),\n setupFixedSize: ({ el, model }) => {\n if (el.style.height)\n return (0,_keplergl__WEBPACK_IMPORTED_MODULE_2__.setupKeplerAutoHeight)(el, model);\n },\n};\n// shinywidgets.css sizes an ipyleaflet map to its (non-filling) output's\n// definite height via flex layout, unless the map has an explicit\n// `Layout(height=...)` (an inline style), which takes precedence. A CSS\n// attribute selector can't tell an inline `height:` apart from `min-height:`/\n// `max-height:`, so track the explicit height with a class instead. Exported\n// so output.ts can also apply it to Map subclasses (their widget_pkg is the\n// subclass's package, not \"ipyleaflet\").\nconst ipyleafletBehavior = {\n setupFixedSize: ({ lmWidget }) => {\n if (!lmWidget.classList.contains('leaflet-widgets'))\n return;\n const sync = () => lmWidget.classList.toggle('shinywidgets-explicit-height', lmWidget.style.height !== '');\n sync();\n const observer = new MutationObserver(sync);\n observer.observe(lmWidget, { attributes: true, attributeFilter: ['style'] });\n return () => observer.disconnect();\n },\n};\n// geolibre embeds its app in an