From 556236f6759fb512653a7b18651147cc5e2c84c8 Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Wed, 31 Jul 2024 18:14:39 -0500 Subject: [PATCH] Filling support for quak; add quak/mosaic to examples (#155) --- examples/outputs/app.py | 84 +++++++++++++++++++++------- examples/outputs/flights.yaml | 51 +++++++++++++++++ examples/outputs/requirements.txt | 6 +- js/src/output.ts | 26 +++++++-- shinywidgets/static/output.js | 6 +- shinywidgets/static/shinywidgets.css | 10 +++- 6 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 examples/outputs/flights.yaml diff --git a/examples/outputs/app.py b/examples/outputs/app.py index 12d63d0..6db2d3a 100644 --- a/examples/outputs/app.py +++ b/examples/outputs/app.py @@ -1,8 +1,12 @@ +from pathlib import Path + import numpy as np from shiny import * from shinywidgets import * +app_dir = Path(__file__).parent + app_ui = ui.page_sidebar( ui.sidebar( ui.input_radio_buttons( @@ -13,6 +17,8 @@ "plotly", "ipyleaflet", "pydeck", + "quak", + "mosaic", "ipysigma", "bokeh", "bqplot", @@ -48,22 +54,26 @@ def _(): source = data.stocks() - return alt.Chart(source).transform_filter( - 'datum.symbol==="GOOG"' - ).mark_area( - tooltip=True, - line={'color': '#0281CD'}, - color=alt.Gradient( - gradient='linear', - stops=[alt.GradientStop(color='white', offset=0), - alt.GradientStop(color='#0281CD', offset=1)], - x1=1, x2=1, y1=1, y2=0 + return ( + alt.Chart(source) + .transform_filter('datum.symbol==="GOOG"') + .mark_area( + tooltip=True, + line={"color": "#0281CD"}, + color=alt.Gradient( + gradient="linear", + stops=[ + alt.GradientStop(color="white", offset=0), + alt.GradientStop(color="#0281CD", offset=1), + ], + x1=1, + x2=1, + y1=1, + y2=0, + ), ) - ).encode( - alt.X('date:T'), - alt.Y('price:Q') - ).properties( - title={"text": ["Google's stock price over time"]} + .encode(alt.X("date:T"), alt.Y("price:Q")) + .properties(title={"text": ["Google's stock price over time"]}) ) @output(id="plotly") @@ -73,8 +83,10 @@ def _(): return px.density_heatmap( px.data.tips(), - x="total_bill", y="tip", - marginal_x="histogram", marginal_y="histogram" + x="total_bill", + y="tip", + marginal_x="histogram", + marginal_y="histogram", ) @output(id="ipyleaflet") @@ -119,14 +131,48 @@ def _(): # Combined all of it and render a viewport return pdk.Deck(layers=[layer], initial_view_state=view_state) + @output(id="quak") + @render_widget + def _(): + import polars as pl + import quak + + df = pl.read_parquet( + "https://github.com/uwdata/mosaic/raw/main/data/athletes.parquet" + ) + return quak.Widget(df) + + @output(id="mosaic") + @render_widget + def _(): + import polars as pl + import yaml + from mosaic_widget import MosaicWidget + + flights = pl.read_parquet( + "https://github.com/uwdata/mosaic/raw/main/data/flights-200k.parquet" + ) + + # Load weather spec, remove data key to ensure load from Pandas + with open(app_dir / "flights.yaml") as f: + spec = yaml.safe_load(f) + _ = spec.pop("data") + + return MosaicWidget(spec, data={"flights": flights}) + @output(id="ipysigma") @render_widget def _(): import igraph as ig from ipysigma import Sigma - g = ig.Graph.Famous('Zachary') - return Sigma(g, node_size=g.degree, node_color=g.betweenness(), node_color_gradient='Viridis') + g = ig.Graph.Famous("Zachary") + return Sigma( + g, + node_size=g.degree, + node_color=g.betweenness(), + node_color_gradient="Viridis", + ) @output(id="bokeh") @render_widget diff --git a/examples/outputs/flights.yaml b/examples/outputs/flights.yaml new file mode 100644 index 0000000..a4b3564 --- /dev/null +++ b/examples/outputs/flights.yaml @@ -0,0 +1,51 @@ +meta: + title: Cross-Filter Flights (200k) + description: > + Histograms showing arrival delay, departure time, and distance flown for over 200,000 flights. + Select a histogram region to cross-filter the charts. + Each plot uses an `intervalX` interactor to populate a shared Selection + with `crossfilter` resolution. +data: + flights: { file: data/flights-200k.parquet } +params: + brush: { select: crossfilter } +vconcat: +- plot: + - mark: rectY + data: { from: flights, filterBy: $brush } + x: { bin: delay } + y: { count: } + fill: steelblue + inset: 0.5 + - select: intervalX + as: $brush + xDomain: Fixed + yTickFormat: s + width: 1200 + height: 250 +- plot: + - mark: rectY + data: { from: flights, filterBy: $brush } + x: { bin: time } + y: { count: } + fill: steelblue + inset: 0.5 + - select: intervalX + as: $brush + xDomain: Fixed + yTickFormat: s + width: 1200 + height: 250 +- plot: + - mark: rectY + data: { from: flights, filterBy: $brush } + x: { bin: distance } + y: { count: } + fill: steelblue + inset: 0.5 + - select: intervalX + as: $brush + xDomain: Fixed + yTickFormat: s + width: 1200 + height: 250 diff --git a/examples/outputs/requirements.txt b/examples/outputs/requirements.txt index a3a3e0e..b7d5c7d 100644 --- a/examples/outputs/requirements.txt +++ b/examples/outputs/requirements.txt @@ -3,15 +3,17 @@ shinywidgets ipywidgets numpy pandas -qgrid vega_datasets bokeh jupyter_bokeh ipyleaflet -pydeck +pydeck==0.8.0 altair plotly bqplot ipychart ipywebrtc vega +quak +mosaic-widget +polars diff --git a/js/src/output.ts b/js/src/output.ts index c92feb3..1ac98fc 100644 --- a/js/src/output.ts +++ b/js/src/output.ts @@ -110,11 +110,15 @@ class IPyWidgetOutput extends Shiny.OutputBinding { // The ipywidgets container (.lmWidget) const lmWidget = el.children[0] as HTMLElement; - this._maybeResize(lmWidget); + if (fill) { + this._onImplementation(lmWidget, () => this._doAddFillClasses(lmWidget)); + } + this._onImplementation(lmWidget, this._doResize); } - _maybeResize(lmWidget: HTMLElement): void { + _onImplementation(lmWidget: HTMLElement, callback: () => void): void { if (this._hasImplementation(lmWidget)) { - return this._doResize(); + callback(); + return; } // Some widget implementation (e.g., ipyleaflet, pydeck) won't actually @@ -122,12 +126,24 @@ class IPyWidgetOutput extends Shiny.OutputBinding { const mo = new MutationObserver((mutations) => { if (this._hasImplementation(lmWidget)) { mo.disconnect(); - this._doResize(); + callback(); } }); mo.observe(lmWidget, {childList: true}); } + // In most cases, we can get widgets to fill through Python/CSS, but some widgets + // (e.g., quak) don't have a Python API and use shadow DOM, which can only access + // from JS + _doAddFillClasses(lmWidget: HTMLElement): void { + const impl = lmWidget.children[0]; + const isQuakWidget = impl && !!impl.shadowRoot?.querySelector(".quak"); + if (isQuakWidget) { + impl.classList.add("html-fill-container", "html-fill-item"); + const quakWidget = impl.shadowRoot.querySelector(".quak") as HTMLElement; + quakWidget.style.maxHeight = "unset"; + } + } _doResize(): void { // Trigger resize event to force layout (setTimeout() is needed for altair) // TODO: debounce this call? @@ -137,7 +153,7 @@ class IPyWidgetOutput extends Shiny.OutputBinding { } _hasImplementation(lmWidget: HTMLElement): boolean { const impl = lmWidget.children[0]; - return impl && impl.children.length > 0; + return impl && (impl.children.length > 0 || impl.shadowRoot?.children.length > 0); } } diff --git a/shinywidgets/static/output.js b/shinywidgets/static/output.js index fc2289a..53ae1f2 100644 --- a/shinywidgets/static/output.js +++ b/shinywidgets/static/output.js @@ -26,7 +26,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 */ \"ShinyComm\": () => (/* binding */ ShinyComm)\n/* harmony export */ });\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\n\n// This class is a striped down version of Comm from @jupyter-widgets/base\n// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/base/src/services-shim.ts#L192-L335\n// Note that the Kernel.IComm implementation is located here\n// https://github.com/jupyterlab/jupyterlab/blob/master/packages/services/src/kernel/comm.ts\nclass ShinyComm {\n constructor(model_id) {\n this.comm_id = model_id;\n // TODO: make this configurable (see comments in send() below)?\n this.throttler = new _utils__WEBPACK_IMPORTED_MODULE_0__.Throttler(100);\n }\n // This might not be needed\n get target_name() {\n return \"jupyter.widgets\";\n }\n send(data, callbacks, metadata, buffers) {\n const msg = {\n content: { comm_id: this.comm_id, data: data },\n metadata: metadata,\n buffers: buffers || [],\n // this doesn't seem relevant to the widget?\n header: {}\n };\n const msg_txt = JSON.stringify(msg);\n // Since ipyleaflet can send mousemove events very quickly when hovering over the map,\n // we throttle them to ensure that the server doesn't get overwhelmed. Said events\n // generate a payload that looks like this:\n // {\"method\": \"custom\", \"content\": {\"event\": \"interaction\", \"type\": \"mousemove\", \"coordinates\": [-17.76259815404015, 12.096729340756617]}}\n //\n // TODO: This is definitely not ideal. It would be better to have a way to specify/\n // customize throttle rates instead of having such a targetted fix for ipyleaflet.\n const is_mousemove = data.method === \"custom\" &&\n data.content.event === \"interaction\" &&\n data.content.type === \"mousemove\";\n if (is_mousemove) {\n this.throttler.throttle(() => {\n Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n });\n }\n else {\n this.throttler.flush();\n Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n }\n // When client-side changes happen to the WidgetModel, this send method\n // won't get called for _every_ change (just the first one). The\n // expectation is that this method will eventually end up calling itself\n // (via callbacks) when the server is ready (i.e., idle) to receive more\n // updates. To make sense of this, see\n // https://github.com/jupyter-widgets/ipywidgets/blob/88cec8b/packages/base/src/widget.ts#L550-L557\n if (callbacks && callbacks.iopub && callbacks.iopub.status) {\n setTimeout(() => {\n // TODO-future: it doesn't seem quite right to report that shiny is always idle.\n // Maybe listen to the shiny-busy flag?\n // const state = document.querySelector(\"html\").classList.contains(\"shiny-busy\") ? \"busy\" : \"idle\";\n const msg = { content: { execution_state: \"idle\" } };\n callbacks.iopub.status(msg);\n }, 0);\n }\n return this.comm_id;\n }\n open(data, callbacks, metadata, buffers) {\n // I don't think we need to do anything here?\n return this.comm_id;\n }\n close(data, callbacks, metadata, buffers) {\n // I don't think we need to do anything here?\n return this.comm_id;\n }\n on_msg(callback) {\n this._msg_callback = callback.bind(this);\n }\n on_close(callback) {\n this._close_callback = callback.bind(this);\n }\n handle_msg(msg) {\n if (this._msg_callback)\n this._msg_callback(msg);\n }\n handle_close(msg) {\n if (this._close_callback)\n this._close_callback(msg);\n }\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/comm.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"ShinyComm\": () => (/* binding */ ShinyComm)\n/* harmony export */ });\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\n\n// This class is a striped down version of Comm from @jupyter-widgets/base\n// https://github.com/jupyter-widgets/ipywidgets/blob/88cec8/packages/base/src/services-shim.ts#L192-L335\n// Note that the Kernel.IComm implementation is located here\n// https://github.com/jupyterlab/jupyterlab/blob/master/packages/services/src/kernel/comm.ts\nclass ShinyComm {\n constructor(model_id) {\n this.comm_id = model_id;\n // TODO: make this configurable (see comments in send() below)?\n this.throttler = new _utils__WEBPACK_IMPORTED_MODULE_0__.Throttler(100);\n }\n // This might not be needed\n get target_name() {\n return \"jupyter.widgets\";\n }\n send(data, callbacks, metadata, buffers) {\n const msg = {\n content: { comm_id: this.comm_id, data: data },\n metadata: metadata,\n // TODO: need to _encode_ any buffers into base64 (JSON.stringify just drops them)\n buffers: buffers || [],\n // this doesn't seem relevant to the widget?\n header: {}\n };\n const msg_txt = JSON.stringify(msg);\n // Since ipyleaflet can send mousemove events very quickly when hovering over the map,\n // we throttle them to ensure that the server doesn't get overwhelmed. Said events\n // generate a payload that looks like this:\n // {\"method\": \"custom\", \"content\": {\"event\": \"interaction\", \"type\": \"mousemove\", \"coordinates\": [-17.76259815404015, 12.096729340756617]}}\n //\n // TODO: This is definitely not ideal. It would be better to have a way to specify/\n // customize throttle rates instead of having such a targetted fix for ipyleaflet.\n const is_mousemove = data.method === \"custom\" &&\n data.content.event === \"interaction\" &&\n data.content.type === \"mousemove\";\n if (is_mousemove) {\n this.throttler.throttle(() => {\n Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n });\n }\n else {\n this.throttler.flush();\n Shiny.setInputValue(\"shinywidgets_comm_send\", msg_txt, { priority: \"event\" });\n }\n // When client-side changes happen to the WidgetModel, this send method\n // won't get called for _every_ change (just the first one). The\n // expectation is that this method will eventually end up calling itself\n // (via callbacks) when the server is ready (i.e., idle) to receive more\n // updates. To make sense of this, see\n // https://github.com/jupyter-widgets/ipywidgets/blob/88cec8b/packages/base/src/widget.ts#L550-L557\n if (callbacks && callbacks.iopub && callbacks.iopub.status) {\n setTimeout(() => {\n // TODO-future: it doesn't seem quite right to report that shiny is always idle.\n // Maybe listen to the shiny-busy flag?\n // const state = document.querySelector(\"html\").classList.contains(\"shiny-busy\") ? \"busy\" : \"idle\";\n const msg = { content: { execution_state: \"idle\" } };\n callbacks.iopub.status(msg);\n }, 0);\n }\n return this.comm_id;\n }\n open(data, callbacks, metadata, buffers) {\n // I don't think we need to do anything here?\n return this.comm_id;\n }\n close(data, callbacks, metadata, buffers) {\n // I don't think we need to do anything here?\n return this.comm_id;\n }\n on_msg(callback) {\n this._msg_callback = callback.bind(this);\n }\n on_close(callback) {\n this._close_callback = callback.bind(this);\n }\n handle_msg(msg) {\n if (this._msg_callback)\n this._msg_callback(msg);\n }\n handle_close(msg) {\n if (this._close_callback)\n this._close_callback(msg);\n }\n}\n\n\n//# sourceURL=webpack://@jupyter-widgets/shiny-embed-manager/./src/comm.ts?"); /***/ }), @@ -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 import */ var _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @jupyter-widgets/html-manager */ \"@jupyter-widgets/html-manager\");\n/* harmony import */ var _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _comm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./comm */ \"./src/comm.ts\");\n/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./utils */ \"./src/utils.ts\");\nvar _a;\n\n\n\n/******************************************************************************\n * Define a custom HTMLManager for use with Shiny\n ******************************************************************************/\nclass OutputManager extends _jupyter_widgets_html_manager__WEBPACK_IMPORTED_MODULE_0__.HTMLManager {\n // In a soon-to-be-released version of @jupyter-widgets/html-manager,\n // display_view()'s first \"dummy\" argument will be removed... this shim simply\n // makes it so that our manager can work with either version\n // https://github.com/jupyter-widgets/ipywidgets/commit/159bbe4#diff-45c126b24c3c43d2cee5313364805c025e911c4721d45ff8a68356a215bfb6c8R42-R43\n async display_view(view, options) {\n const n_args = super.display_view.length;\n if (n_args === 3) {\n return super.display_view({}, view, options);\n }\n else {\n // @ts-ignore\n return super.display_view(view, options);\n }\n }\n}\n// Define our own custom module loader for Shiny\nconst shinyRequireLoader = async function (moduleName, moduleVersion) {\n // shiny provides require.js and also sets `define.amd=false` to prevent