-
-
Notifications
You must be signed in to change notification settings - Fork 844
Add Anywidget Front-End Module spec support with ui.anywidget()
#5137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
azjps
wants to merge
5
commits into
zauberzeug:main
Choose a base branch
from
azjps:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d2ceb10
Support AFM via ui.anywidget (#5096)
azjps c69f4fd
Cleanup anywidget module
azjps c896d6e
Add nicegui.ui.altair wrapper
azjps 3b66938
Update anywidget code for nicegui v3.0 conventions
azjps 8431a77
Add unit test and website documentation for anywidget/altair
azjps File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
#!/usr/bin/env python3 | ||
"""Demonstrates embedding altair charts in NiceGUI. | ||
|
||
This shows how to embed altair charts in NiceGUI, and how to synchronize | ||
NiceGUI elements with charts using ``.bind_*`` (chart -> NiceGUI) and ``on_*`` | ||
callbacks (NiceGUI -> chart). | ||
""" | ||
|
||
import altair as alt | ||
import numpy as np | ||
import pandas as pd | ||
|
||
from nicegui import ui | ||
|
||
|
||
def create_altair_chart() -> alt.Chart: | ||
"""Create simple altair chart with slider-based coloring | ||
|
||
Refer to: | ||
https://altair-viz.github.io/user_guide/interactions/jupyter_chart.html#accessing-variable-params | ||
""" | ||
rand = np.random.RandomState(42) | ||
|
||
df = pd.DataFrame({ | ||
'xval': range(100), | ||
'yval': rand.randn(100).cumsum() | ||
}) | ||
|
||
slider = alt.binding_range(min=0, max=100, step=1) | ||
cutoff = alt.param(name='cutoff', bind=slider, value=50) | ||
|
||
chart = alt.Chart(df).mark_point().encode( | ||
x='xval', | ||
y='yval', | ||
color=alt.condition( | ||
alt.datum.xval < cutoff, | ||
alt.value('red'), alt.value('blue') | ||
) | ||
).add_params( | ||
cutoff | ||
) | ||
return chart | ||
|
||
|
||
@ui.page('/') | ||
def page(): | ||
# Example 1: getting started example from altair documentation | ||
# https://altair-viz.github.io/getting_started/starting.html#customizing-your-visualization | ||
with ui.card().classes('min-w-200px max-w-300px'): | ||
ui.label('Static altair chart').classes('text-xl font-bold') | ||
|
||
data = pd.DataFrame({'a': list('CCCDDDEEE'), 'b': [2, 7, 4, 1, 2, 6, 8, 4, 7]}) | ||
chart = alt.Chart(data).mark_bar(color='firebrick').encode( | ||
alt.Y('a').title('category'), | ||
alt.X('average(b)').title('avg(b) by category') | ||
) | ||
ui.altair(chart) | ||
|
||
# Example 2: altair chart widget with slider synchronized between nicegui & altair | ||
# This is the JupyterChart variable params example from the altair documentation: | ||
# https://altair-viz.github.io/user_guide/interactions/jupyter_chart.html#accessing-variable-params | ||
with ui.card().classes('min-w-200px max-w-300px'): | ||
ui.label('Synchronized altair and nicegui sliders').classes('text-xl font-bold') | ||
# jchart = AltairChart(create_altair_chart()) | ||
chart = create_altair_chart() | ||
widget = ui.altair(chart, throttle=0.5) | ||
|
||
def update_cutoff(change): | ||
# NOTE: as of altair v5.5, there is a JavaScript side bug in altair.JupyterChart | ||
# that causes this callback to fail when accessing .changed (only supported in Jupyter): | ||
# https://github.com/vega/altair/issues/3868 | ||
widget._widget.params.cutoff = change.value | ||
slider = ui.slider( | ||
min=0, max=100, value=widget._widget.params.cutoff, on_change=update_cutoff, throttle=0.2 | ||
).bind_value_from(widget._widget, '_params', backward=lambda p: p['cutoff']) | ||
ui.label().bind_text_from(slider, 'value', backward=lambda c: f'Cutoff is {c}') | ||
|
||
|
||
ui.run(show=False) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
#!/usr/bin/env python3 | ||
"""Demonstrates embedding anywidget widgets in NiceGUI. | ||
|
||
This shows how to embed anywidget widgets in NiceGUI, and how to synchronize | ||
NiceGUI elements with widgets using ``.bind_*`` (widget -> NiceGUI) and ``on_*`` | ||
callbacks (NiceGUI -> widget). | ||
|
||
The example shows how to embed a counter widget and an altair chart widget | ||
from the getting started examples in anywidget and altair. | ||
""" | ||
|
||
import anywidget | ||
import traitlets | ||
|
||
from nicegui import ui | ||
|
||
|
||
class CounterWidget(anywidget.AnyWidget): | ||
"""Baseline anywidget example""" | ||
_esm = ''' | ||
function render({ model, el }) { | ||
let button = document.createElement("button"); | ||
button.innerHTML = `anywidget count is ${model.get("value")}`; | ||
button.addEventListener("click", () => { | ||
model.set("value", model.get("value") + 1); | ||
model.save_changes(); | ||
}); | ||
model.on("change:value", () => { | ||
button.innerHTML = `anywidget count is ${model.get("value")}`; | ||
}); | ||
el.classList.add("counter-widget"); | ||
el.appendChild(button); | ||
} | ||
export default { render }; | ||
''' | ||
_css = ''' | ||
.counter-widget button { color: white; font-size: 1.75rem; background-color: #ea580c; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; } | ||
.counter-widget button:hover { background-color: #9a3412; } | ||
''' | ||
value = traitlets.Int(0).tag(sync=True) | ||
|
||
|
||
def create_altair_chart(): | ||
"""Create simple altair chart with slider-based coloring | ||
|
||
Refer to: | ||
https://altair-viz.github.io/user_guide/interactions/jupyter_chart.html#accessing-variable-params | ||
""" | ||
import altair as alt | ||
import numpy as np | ||
import pandas as pd | ||
|
||
rand = np.random.RandomState(42) | ||
|
||
df = pd.DataFrame({ | ||
'xval': range(100), | ||
'yval': rand.randn(100).cumsum() | ||
}) | ||
|
||
slider = alt.binding_range(min=0, max=100, step=1) | ||
cutoff = alt.param(name='cutoff', bind=slider, value=50) | ||
|
||
chart = alt.Chart(df).mark_point().encode( | ||
x='xval', | ||
y='yval', | ||
color=alt.condition( | ||
alt.datum.xval < cutoff, | ||
alt.value('red'), alt.value('blue') | ||
) | ||
).add_params( | ||
cutoff | ||
) | ||
return alt.JupyterChart(chart) | ||
|
||
|
||
@ui.page('/') | ||
def page(): | ||
with ui.row(): | ||
with ui.column(): | ||
# Example 1: counter widget synchronized with nicegui & anywidget | ||
with ui.card().classes('min-w-200px max-w-300px'): | ||
ui.label('Synchronized anywidget and nicegui buttons').classes('text-xl font-bold') | ||
ui.markdown(''' | ||
This is the getting started example from the | ||
[`anywidget` documentation](https://anywidget.dev/en/getting-started/). | ||
|
||
`anywidget` gives us a way to write to bridge arbitrary JavaScript | ||
libraries with Python with compatibility for multiple frontends (such as Jupyter/Marimo). | ||
|
||
We can synchronize `anywidget` and NiceGUI state in python using a mix of | ||
`traitlets` callbacks and NiceGUI's `bind_*` methods. | ||
''') | ||
counter = CounterWidget(value=42) | ||
ui.anywidget(counter) | ||
|
||
def increment_counter(): | ||
counter.value += 1 | ||
ui.button( | ||
f'NiceGUI count is {counter.value}', on_click=increment_counter | ||
).bind_text_from(counter, 'value', backward=lambda c: f'NiceGUI count is {c}') | ||
|
||
# Example 2: altair chart widget with slider synchronized between nicegui & altair | ||
with ui.card().classes('min-w-200px max-w-300px'): | ||
ui.label('Synchronized altair and nicegui sliders').classes('text-xl font-bold') | ||
|
||
ui.markdown(''' | ||
This is the JupyterChart variable params example from the | ||
[`altair` documentation](https://altair-viz.github.io/user_guide/interactions/jupyter_chart.html#accessing-variable-params). | ||
|
||
*Note: In `altair<=5.5`, the nicegui -> altair slider synchronization might | ||
not work due to | ||
[this bug in `altair.JupyterChart`.](https://github.com/vega/altair/issues/3868) | ||
''') | ||
|
||
# jchart = AltairChart(create_altair_chart()) | ||
jchart = create_altair_chart() | ||
ui.anywidget(jchart, throttle=0.5) | ||
|
||
def update_cutoff(change): | ||
# NOTE: as of altair v5.5, there is a JavaScript side bug in altair.JupyterChart | ||
# that causes this callback to fail when accessing .changed (only supported in Jupyter): | ||
# https://github.com/vega/altair/issues/3868 | ||
jchart.params.cutoff = change.value | ||
slider = ui.slider( | ||
min=0, max=100, value=jchart.params.cutoff, on_change=update_cutoff, throttle=0.2 | ||
).bind_value_from(jchart, '_params', backward=lambda p: p['cutoff']) | ||
ui.label().bind_text_from(slider, 'value', backward=lambda c: f'Cutoff is {c}') | ||
|
||
|
||
ui.run(show=False) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
from __future__ import annotations | ||
|
||
import importlib.util | ||
from typing import TYPE_CHECKING, Any | ||
|
||
from ... import optional_features | ||
from .anywidget import AnyWidget | ||
|
||
if importlib.util.find_spec('altair'): | ||
optional_features.register('altair') | ||
if TYPE_CHECKING: | ||
import altair | ||
|
||
|
||
class Altair(AnyWidget): | ||
def __init__(self, chart: altair.Chart | altair.JupyterChart, **kwargs: Any) -> None: | ||
"""altair Chart | ||
|
||
Wrap an `altair.Chart` or `altair.JupyterChart` in NiceGUI via `anywidget`. | ||
|
||
Refer to the `altair documentation <https://altair-viz.github.io/user_guide/interactions/jupyter_chart.html#accessing-variable-params>`_ | ||
for more information about synchronizing `altair` parameters with python. | ||
|
||
:param chart: the `altair.Chart` or `altair.JupyterChart` to wrap | ||
:param throttle: minimum time (in seconds) between widget updates to python (default: 0.0) | ||
""" | ||
import altair | ||
if isinstance(chart, altair.Chart): | ||
chart = altair.JupyterChart(chart) | ||
super().__init__(chart, **kwargs) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import { load_widget, load_css } from "widget"; // lib/anywidget/widget.js | ||
import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js"; | ||
|
||
export default { | ||
template: "<div></div>", | ||
mounted() { | ||
this.init_widget(); | ||
}, | ||
methods: { | ||
_log(...args) { | ||
if (this._debug) { | ||
console.log("NiceGUI-Anywidget", ...args); | ||
} | ||
}, | ||
init_widget() { | ||
(async () => { | ||
const emit_to_py = this.$emit; | ||
const log = this._log; | ||
|
||
// Implement AFM: https://anywidget.dev/en/afm/ | ||
// References: | ||
// * Marimo AFM impl: | ||
// https://github.com/marimo-team/marimo/blob/7f3023ff0caef22b2bf4c1b5a18ad1899bd40fa3/frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx#L161-L267 | ||
const model = { | ||
attributes: { ...this.traits }, | ||
callbacks: {}, | ||
get: function (key) { | ||
log('Getting value for', key, ':', this.attributes[key]); | ||
const value = this.attributes[key]; | ||
try { | ||
// TODO: this should not be necessary but was running into some | ||
// JavaScript issues that haven't tried to figure out | ||
return JSON.parse(JSON.stringify(value)); | ||
} catch (e) { | ||
// If value is not serializable, return null or a fallback | ||
console.warn('NiceGUI-Anywidget: Value for key', key, 'is not JSON-serializable:', value); | ||
return null; | ||
} | ||
}, | ||
set: function (key, value) { | ||
log('Setting value for', key, ':', value); | ||
this.attributes[key] = value; | ||
this.emit('change:' + key, value); | ||
}, | ||
save_changes: function () { | ||
log('Saving changes:', this.attributes); | ||
|
||
// Trigger any change callbacks | ||
if (this.callbacks['change'] && Array.isArray(this.callbacks['change'])) { | ||
this.callbacks['change'].forEach((cb) => cb()); | ||
} | ||
|
||
// Propagate the change back to python backend; | ||
// currently serializing all traits instead of just the changed ones | ||
// (ideally would do this to reduce communication overhead) | ||
emit_to_py('update:traits', { ...this.attributes }); | ||
}, | ||
on: function (event, callback) { | ||
log('Registering callback for event:', event); | ||
if (!this.callbacks[event]) { | ||
this.callbacks[event] = []; | ||
} | ||
this.callbacks[event].push(callback); | ||
}, | ||
off: function (event, callback) { | ||
if (!event) { | ||
this.callbacks = {}; | ||
return; | ||
} | ||
if (!callback) { | ||
this.callbacks[event] = []; | ||
return; | ||
} | ||
this.callbacks[event]?.delete(callback); | ||
}, | ||
emit: function (event, value) { | ||
if (this.callbacks[event]) { | ||
this.callbacks[event].forEach(cb => cb(value)); | ||
} | ||
}, | ||
send: function (content, callbacks, buffers) { | ||
if (buffers) { | ||
console.warn('anywidget.send() buffers are not supported in NiceGUI currently'); | ||
} else { | ||
console.warn('anywidget.send() is not yet implemented in NiceGUI;', content); | ||
} | ||
// emit_to_py('custom', content); | ||
} | ||
}; | ||
|
||
// Dynamically load esm_content as an ECMAScript module | ||
const mod = await load_widget(this.esm_content, this.traits["_anywidget_id"]); | ||
// TODO: cleanup_widget and cleanup_view should be called when the widget is destroyed | ||
this.cleanup_widget = await mod.initialize?.({ model: model }); | ||
this.cleanup_view = await mod.render?.({ model: model, el: this.$el }); | ||
this.model = model; | ||
})(); | ||
|
||
load_css(this.css_content, this.traits["_anywidget_id"]); | ||
|
||
// If you have an API to add listeners, do so here (placeholder) | ||
// this.api.addGlobalListener(this.handle_event); | ||
}, | ||
update_trait(change) { | ||
// Callback from Python traitlet backend change event | ||
// change is a dictionary with 'trait', 'new', and 'old' keys | ||
convertDynamicProperties(change, true); | ||
this._log('Updating trait:', change); | ||
if (change) { | ||
this.model.attributes[change['trait']] = change['new']; | ||
this.model.emit("change:" + change['trait'], change['new']); | ||
} | ||
}, | ||
update_traits() { | ||
// Currently no-op | ||
this._log('Updating traits:', this.traits, this.model.attributes); | ||
}, | ||
handle_event(type, args) { | ||
// Currently unused | ||
this._log('handle_event', type, args); | ||
}, | ||
}, | ||
props: { | ||
traits: Object, | ||
esm_content: String, | ||
css_content: String, | ||
_debug: Boolean, | ||
}, | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this just
return value
, this raises an error: