Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `@render_ipyvolume` for [ipyvolume](https://ipyvolume.readthedocs.io) 3D scatter/quiver/volume plots. Both the pylab API (`ipv.quickscatter()`, `ipv.gcf()`) and direct construction (`ipyvolume.Figure` + `ipyvolume.widgets.Scatter`/etc.) work, including for outputs that re-render. The WebGL canvas fills (and resizes with) its container unless the figure has an explicit size, and rectangle/lasso selections and camera state round-trip to Python. (#278)
* `@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_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_geolibre` for [GeoLibre](https://geolibre.app) maps. 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. 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). The canvas fills its container's width while preserving the drawing's aspect ratio. (#278)
* `@render_obsplot` for [Observable Plot](https://observablehq.com/plot/) charts built with [pyobsplot](https://juba.github.io/pyobsplot/). 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)
* `@render_plotly` now accepts a Dash-style `(figure, config)` tuple return value, where `config` is a dict of [Plotly.js config options](https://plotly.com/javascript/configuration-options/) (e.g., `{"displayModeBar": False}`). The config is merged over plotly's defaults and takes precedence over shinywidgets' own defaults. (#173)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]'` |
Expand Down
36 changes: 36 additions & 0 deletions examples/drawdata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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 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`
always starts empty: its frontend overwrites constructor-provided `data`
at first render (seeding is impossible, even via a re-render) and ignores
later Python-side trait updates until the next re-render (upstream
drawdata [issue #42](https://github.com/koaning/drawdata/issues/42)), so
re-render -- return a new `BarWidget` -- to change settings like
`n_bins`. Avoid two `BarWidget`s on the same page for now: their
collection buttons interfere
([issue #43](https://github.com/koaning/drawdata/issues/43)).
99 changes: 99 additions & 0 deletions examples/drawdata/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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. Process the grid in
# chunks (using |a-b|^2 = |a|^2 + |b|^2 - 2*a.b, so no Nx3
# broadcast intermediate) so peak memory stays bounded no matter
# how many points are drawn, and let argpartition select the k
# nearest neighbors instead of fully sorting every row.
gx, gy = np.meshgrid(np.linspace(0, W, 100), np.linspace(0, H, 50))
grid = np.c_[gx.ravel(), gy.ravel()]
k = min(5, len(X))
x2 = (X**2).sum(1)
votes = np.empty(len(grid), dtype=int)
for start in range(0, len(grid), 1000):
g = grid[start : start + 1000]
d2 = (g**2).sum(1)[:, None] + x2[None, :] - 2 * (g @ X.T)
knn = np.argpartition(d2, min(k, len(X) - 1), axis=1)[:, :k]
counts = np.zeros((len(g), len(classes)), dtype=int)
np.add.at(counts, (np.arange(len(g))[:, None], y_idx[knn]), 1)
votes[start : start + len(g)] = counts.argmax(1)
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)
)
5 changes: 5 additions & 0 deletions examples/drawdata/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
shiny
shinywidgets[drawdata]
matplotlib
numpy
pandas
105 changes: 105 additions & 0 deletions js/src/fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,112 @@ 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 <svg> gets an inline `max-width: {width}px`
// (and .dd-scatter-container a CSS `max-width: 1200px`), and BarWidget's
// chart <svg> gets fixed width/height attributes with no viewBox. 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.
//
// In a sized output (a filling layout, or output_widget(height=)), scaling
// to the full width can make the chart taller than the output (e.g., a
// 1200x500 output grows the default 800x400 ScatterWidget to 1200x600),
// which the output's overflow:hidden then clips. So when the output's
// height is definite, also constrain the chart's width by the height left
// over after the controls. Returns a cleanup function when fitting.
function relaxDrawdataSize(ctx: BehaviorContext, fitHeight: boolean): (() => void) | void {
const { el, lmWidget } = ctx;
// 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<HTMLElement>(
'.dd-scatter-container, .container'
)) {
container.style.maxWidth = 'none';
container.style.marginTop = '0';
container.style.marginBottom = '0';
}
// BarWidget's chart wrapper also carries a 20px bottom margin
for (const wrap of lmWidget.querySelectorAll<HTMLElement>('.chart-container')) {
wrap.style.marginBottom = '0';
}
// Find the chart svg: ScatterWidget's is the one with an inline pixel
// max-width; BarWidget's is the one with pixel width/height attributes
// and no viewBox (button icons are excluded -- they already have one).
let chart: SVGSVGElement | undefined;
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';
chart = svg;
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}`;
chart = svg;
}
}

// Fitting the height only makes sense when the output's height is
// definite -- otherwise it's derived from the widget's own height, and
// shrinking the chart would shrink the output (a feedback loop).
//
// BarWidget's chart svg is a direct child of its (classless, drawdata
// 0.5.x) container, alongside the controls -- so measure the chart's own
// position, not its parent's, to account for the controls above it.
const wrap = chart?.parentElement;
if (!fitHeight || !chart || !wrap) return;
const chartSvg = chart;
const chartWrap = wrap;

const fit = () => {
const viewBox = chartSvg.viewBox.baseVal;
if (viewBox.width <= 0 || viewBox.height <= 0) return;
// Available height = the output's bottom minus everything physically
// above the chart (controls, margins). Measuring below-chart slack off
// a container doesn't work in fill layouts, where the fill CSS chain
// sizes the widget's containers to the output (so their bottoms no
// longer track the chart's).
const availH =
el.getBoundingClientRect().bottom - chartSvg.getBoundingClientRect().top;
const availW = chartWrap.clientWidth;
const w = Math.floor(Math.min(availW, (availH * viewBox.width) / viewBox.height));
chartSvg.style.width = `${Math.max(w, 1)}px`;
};

// Observing el (not the widget) is a fixed point: the chart's size changes
// never change the definite-height output.
const observer = new ResizeObserver(fit);
observer.observe(el);
fit();
return () => observer.disconnect();
}

const drawdataBehavior: WidgetBehavior = {
setupFill: (ctx) => relaxDrawdataSize(ctx, true),
// 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. Only an
// explicit output height is definite here, so only then fit the height.
setupFixedSize: (ctx) => relaxDrawdataSize(ctx, !!ctx.el.style.height),
};

export const WIDGET_BEHAVIORS: Record<string, WidgetBehavior> = {
drawdata: drawdataBehavior,
ipympl: ipymplBehavior,
ipyvolume: ipyvolumeBehavior,
jscatter: jscatterBehavior,
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"]
Expand Down Expand Up @@ -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'",
Expand Down Expand Up @@ -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'",
Expand Down Expand Up @@ -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'",
Expand Down
2 changes: 2 additions & 0 deletions shinywidgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
render_altair,
render_bokeh,
render_bqplot,
render_drawdata,
render_embedding_atlas,
render_geolibre,
render_holoviews,
Expand Down Expand Up @@ -67,6 +68,7 @@
"render_altair",
"render_bokeh",
"render_bqplot",
"render_drawdata",
"render_embedding_atlas",
"render_geolibre",
"render_holoviews",
Expand Down
Loading
Loading