Skip to content
Open
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
79 changes: 79 additions & 0 deletions examples/altair/main.py
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)
130 changes: 130 additions & 0 deletions examples/anywidget/main.py
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)
30 changes: 30 additions & 0 deletions nicegui/elements/anywidget/altair.py
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)
129 changes: 129 additions & 0 deletions nicegui/elements/anywidget/anywidget.js
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];
Copy link
Author

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:

Uncaught (in promise) DataCloneError: Failed to execute 'structuredClone' on 'Window': #<Object> could not be cloned.
    at reembed (1b7ef5d7-1cf7-4947-8f06-0f33e5db9f95:29:20)
    at Object.render (1b7ef5d7-1cf7-4947-8f06-0f33e5db9f95:146:11)
    at anywidget.js:96:47

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,
},
};
Loading