diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e25bcb..a2ed95f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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..9075e850 --- /dev/null +++ b/examples/drawdata/README.md @@ -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)). diff --git a/examples/drawdata/app.py b/examples/drawdata/app.py new file mode 100644 index 00000000..a51d3b00 --- /dev/null +++ b/examples/drawdata/app.py @@ -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) + ) 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/js/src/fill.ts b/js/src/fill.ts index d3e73e43..e21ca424 100644 --- a/js/src/fill.ts +++ b/js/src/fill.ts @@ -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 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. 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( + '.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('.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 = { + drawdata: drawdataBehavior, ipympl: ipymplBehavior, ipyvolume: ipyvolumeBehavior, jscatter: jscatterBehavior, 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/__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..8d24857f 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,94 @@ 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]'``). + 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 + 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 a few `BarWidget` limitations (upstream drawdata issue + [#42](https://github.com/koaning/drawdata/issues/42)): its frontend + overwrites any ``data`` passed to the constructor when it initializes + (all bins start at ``y_min``), so a `BarWidget` output always starts + empty -- seeding initial data is impossible, even via a re-render -- + and it ignores later Python-side trait updates until the next re-render + (it registers no ``change:`` handlers, unlike `ScatterWidget`). + Re-rendering (returning a new `BarWidget` in response to a reactive + change) is the way to change ``n_bins``, ``y_max``, etc. Finally, avoid + putting two `BarWidget`s on the same page: clicking one widget's + collection buttons silently switches the other's active collection + (upstream drawdata issue + [#43](https://github.com/koaning/drawdata/issues/43)). + + 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 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. + 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/static/output.js b/shinywidgets/static/output.js index 002bad7d..cbca887d 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