-
Notifications
You must be signed in to change notification settings - Fork 794
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
Add online JupyterChart widget based on AnyWidget #3119
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
775995a
Add ChartWidget based on AnyWidget
jonmmease ff29ea1
lodash to just-debounce-it for size reduction
jonmmease cf80f25
Rename ChartWidget to JupyterChart
jonmmease cce1579
Get rid of _param_watches (just use param keys)
jonmmease fa629d2
remove set_params method
jonmmease 7a6ef41
rename params Dict traitlet to _params
jonmmease 329860b
Store params in a traitlet object
jonmmease bf2e26d
Make selections prop a dynamic traitlet class
jonmmease d726038
Remove selection watches traitlet
jonmmease 84a5503
black
jonmmease 7418f46
Update tests
jonmmease 31abc8d
Use runAsync to avoid race condition
jonmmease f2d7575
Skip vegafusion test when not installed
jonmmease fbb2932
Empty-Commit
jonmmease 037b84f
Show errors the same was as the HTML renderer
jonmmease e87a3ee
mypy / ruff
jonmmease 477b0c5
Update altair/jupyter/js/README.md
jonmmease cc13950
Update altair/jupyter/js/README.md
jonmmease 9df3d52
Add kernel restart to message
jonmmease 948a23d
Import JupyterChart in else
jonmmease d27e458
import from top-level
jonmmease 0e574fa
mypy
jonmmease 2e4a48e
Move non-widget selection logic to `util.selection`
jonmmease 0fa4723
Use lodash's debounce for maxWait functionality
jonmmease 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 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 |
---|---|---|
|
@@ -74,4 +74,4 @@ Untitled*.ipynb | |
.vscode | ||
|
||
# hatch, doc generation | ||
data.json | ||
data.json |
This file contains 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
This file contains 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,20 @@ | ||
try: | ||
import anywidget # noqa: F401 | ||
except ImportError: | ||
# When anywidget isn't available, create stand-in JupyterChart class | ||
# that raises an informative import error on construction. This | ||
# way we can make JupyterChart available in the altair namespace | ||
# when anywidget is not installed | ||
class JupyterChart: | ||
def __init__(self, *args, **kwargs): | ||
raise ImportError( | ||
"The Altair JupyterChart requires the anywidget \n" | ||
"Python package which may be installed using pip with\n" | ||
" pip install anywidget\n" | ||
"or using conda with\n" | ||
" conda install -c conda-forge anywidget\n" | ||
"Afterwards, you will need to restart your Python kernel." | ||
) | ||
|
||
else: | ||
from .jupyter_chart import JupyterChart # noqa: F401 |
This file contains 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,2 @@ | ||
# JupyterChart | ||
This directory contains the JavaScript portion of the Altair `JupyterChart`. The `JupyterChart` is based on the [AnyWidget](https://anywidget.dev/) project. |
This file contains 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,80 @@ | ||
import embed from "https://cdn.jsdelivr.net/npm/vega-embed@6/+esm"; | ||
import { debounce } from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js" | ||
|
||
export async function render({ model, el }) { | ||
let finalize; | ||
|
||
function showError(error){ | ||
el.innerHTML = ( | ||
'<div style="color:red;">' | ||
+ '<p>JavaScript Error: ' + error.message + '</p>' | ||
+ "<p>This usually means there's a typo in your chart specification. " | ||
+ "See the javascript console for the full traceback.</p>" | ||
+ '</div>' | ||
); | ||
} | ||
|
||
const reembed = async () => { | ||
if (finalize != null) { | ||
finalize(); | ||
} | ||
|
||
let spec = model.get("spec"); | ||
let api; | ||
try { | ||
api = await embed(el, spec); | ||
} catch (error) { | ||
showError(error) | ||
return; | ||
} | ||
|
||
finalize = api.finalize; | ||
|
||
// Debounce config | ||
const wait = model.get("debounce_wait") ?? 10; | ||
const maxWait = wait; | ||
|
||
const initialSelections = {}; | ||
for (const selectionName of Object.keys(model.get("_vl_selections"))) { | ||
const selectionHandler = (_, value) => { | ||
const newSelections = JSON.parse(JSON.stringify(model.get("_vl_selections"))) || {}; | ||
const store = JSON.parse(JSON.stringify(api.view.data(`${selectionName}_store`))); | ||
|
||
newSelections[selectionName] = {value, store}; | ||
model.set("_vl_selections", newSelections); | ||
model.save_changes(); | ||
}; | ||
api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait})); | ||
|
||
initialSelections[selectionName] = {value: {}, store: []} | ||
} | ||
model.set("_vl_selections", initialSelections); | ||
|
||
const initialParams = {}; | ||
for (const paramName of Object.keys(model.get("_params"))) { | ||
const paramHandler = (_, value) => { | ||
const newParams = JSON.parse(JSON.stringify(model.get("_params"))) || {}; | ||
newParams[paramName] = value; | ||
model.set("_params", newParams); | ||
model.save_changes(); | ||
}; | ||
api.view.addSignalListener(paramName, debounce(paramHandler, wait, {maxWait})); | ||
|
||
initialParams[paramName] = api.view.signal(paramName) ?? null | ||
} | ||
model.set("_params", initialParams); | ||
model.save_changes(); | ||
|
||
// Param change callback | ||
model.on('change:_params', async (new_params) => { | ||
for (const [param, value] of Object.entries(new_params.changed._params)) { | ||
api.view.signal(param, value); | ||
} | ||
await api.view.runAsync(); | ||
}); | ||
} | ||
|
||
model.on('change:spec', reembed); | ||
model.on('change:debounce_wait', reembed); | ||
await reembed(); | ||
} |
This file contains 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,238 @@ | ||
import anywidget | ||
import traitlets | ||
binste marked this conversation as resolved.
Show resolved
Hide resolved
|
||
import pathlib | ||
from typing import Any | ||
|
||
import altair as alt | ||
from altair.utils._vegafusion_data import using_vegafusion | ||
from altair import TopLevelSpec | ||
from altair.utils.selection import IndexSelection, PointSelection, IntervalSelection | ||
|
||
_here = pathlib.Path(__file__).parent | ||
|
||
|
||
class Params(traitlets.HasTraits): | ||
""" | ||
Traitlet class storing a JupyterChart's params | ||
""" | ||
|
||
def __init__(self, trait_values): | ||
super().__init__() | ||
|
||
for key, value in trait_values.items(): | ||
if isinstance(value, int): | ||
traitlet_type = traitlets.Int() | ||
elif isinstance(value, float): | ||
traitlet_type = traitlets.Float() | ||
elif isinstance(value, str): | ||
traitlet_type = traitlets.Unicode() | ||
elif isinstance(value, list): | ||
traitlet_type = traitlets.List() | ||
elif isinstance(value, dict): | ||
traitlet_type = traitlets.Dict() | ||
else: | ||
raise ValueError(f"Unexpected param type: {type(value)}") | ||
|
||
# Add the new trait. | ||
self.add_traits(**{key: traitlet_type}) | ||
|
||
# Set the trait's value. | ||
setattr(self, key, value) | ||
|
||
def __repr__(self): | ||
return f"Params({self.trait_values()})" | ||
|
||
|
||
class Selections(traitlets.HasTraits): | ||
""" | ||
Traitlet class storing a JupyterChart's selections | ||
""" | ||
|
||
def __init__(self, trait_values): | ||
super().__init__() | ||
|
||
for key, value in trait_values.items(): | ||
if isinstance(value, IndexSelection): | ||
traitlet_type = traitlets.Instance(IndexSelection) | ||
elif isinstance(value, PointSelection): | ||
traitlet_type = traitlets.Instance(PointSelection) | ||
elif isinstance(value, IntervalSelection): | ||
traitlet_type = traitlets.Instance(IntervalSelection) | ||
else: | ||
raise ValueError(f"Unexpected selection type: {type(value)}") | ||
|
||
# Add the new trait. | ||
self.add_traits(**{key: traitlet_type}) | ||
|
||
# Set the trait's value. | ||
setattr(self, key, value) | ||
|
||
# Make read-only | ||
self.observe(self._make_read_only, names=key) | ||
|
||
def __repr__(self): | ||
return f"Selections({self.trait_values()})" | ||
|
||
def _make_read_only(self, change): | ||
""" | ||
Work around to make traits read-only, but still allow us to change | ||
them internally | ||
""" | ||
if change["name"] in self.traits() and change["old"] != change["new"]: | ||
self._set_value(change["name"], change["old"]) | ||
raise ValueError( | ||
"Selections may not be set from Python.\n" | ||
f"Attempted to set select: {change['name']}" | ||
) | ||
|
||
def _set_value(self, key, value): | ||
self.unobserve(self._make_read_only, names=key) | ||
setattr(self, key, value) | ||
self.observe(self._make_read_only, names=key) | ||
|
||
|
||
class JupyterChart(anywidget.AnyWidget): | ||
_esm = _here / "js" / "index.js" | ||
_css = r""" | ||
.vega-embed { | ||
/* Make sure action menu isn't cut off */ | ||
overflow: visible; | ||
} | ||
""" | ||
|
||
# Public traitlets | ||
chart = traitlets.Instance(TopLevelSpec) | ||
spec = traitlets.Dict().tag(sync=True) | ||
debounce_wait = traitlets.Float(default_value=10).tag(sync=True) | ||
|
||
# Internal selection traitlets | ||
_selection_types = traitlets.Dict() | ||
_vl_selections = traitlets.Dict().tag(sync=True) | ||
|
||
# Internal param traitlets | ||
_params = traitlets.Dict().tag(sync=True) | ||
|
||
def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): | ||
""" | ||
Jupyter Widget for displaying and updating Altair Charts, and | ||
retrieving selection and parameter values | ||
|
||
Parameters | ||
---------- | ||
chart: Chart | ||
Altair Chart instance | ||
debounce_wait: int | ||
Debouncing wait time in milliseconds | ||
""" | ||
self.params = Params({}) | ||
self.selections = Selections({}) | ||
super().__init__(chart=chart, debounce_wait=debounce_wait, **kwargs) | ||
|
||
@traitlets.observe("chart") | ||
def _on_change_chart(self, change): | ||
""" | ||
Internal callback function that updates the JupyterChart's internal | ||
state when the wrapped Chart instance changes | ||
""" | ||
new_chart = change.new | ||
|
||
params = getattr(new_chart, "params", []) | ||
selection_watches = [] | ||
selection_types = {} | ||
initial_params = {} | ||
initial_vl_selections = {} | ||
empty_selections = {} | ||
|
||
if params is not alt.Undefined: | ||
for param in new_chart.params: | ||
select = getattr(param, "select", alt.Undefined) | ||
|
||
if select != alt.Undefined: | ||
if not isinstance(select, dict): | ||
select = select.to_dict() | ||
|
||
select_type = select["type"] | ||
if select_type == "point": | ||
if not ( | ||
select.get("fields", None) or select.get("encodings", None) | ||
): | ||
# Point selection with no associated fields or encodings specified. | ||
# This is an index-based selection | ||
selection_types[param.name] = "index" | ||
empty_selections[param.name] = IndexSelection( | ||
name=param.name, value=[], store=[] | ||
) | ||
else: | ||
selection_types[param.name] = "point" | ||
empty_selections[param.name] = PointSelection( | ||
name=param.name, value=[], store=[] | ||
) | ||
elif select_type == "interval": | ||
selection_types[param.name] = "interval" | ||
empty_selections[param.name] = IntervalSelection( | ||
name=param.name, value={}, store=[] | ||
) | ||
else: | ||
raise ValueError(f"Unexpected selection type {select.type}") | ||
selection_watches.append(param.name) | ||
initial_vl_selections[param.name] = {"value": None, "store": []} | ||
else: | ||
clean_value = param.value if param.value != alt.Undefined else None | ||
initial_params[param.name] = clean_value | ||
|
||
# Setup params | ||
self.params = Params(initial_params) | ||
|
||
def on_param_traitlet_changed(param_change): | ||
new_params = dict(self._params) | ||
new_params[param_change["name"]] = param_change["new"] | ||
self._params = new_params | ||
|
||
self.params.observe(on_param_traitlet_changed) | ||
|
||
# Setup selections | ||
self.selections = Selections(empty_selections) | ||
|
||
# Update properties all together | ||
with self.hold_sync(): | ||
if using_vegafusion(): | ||
self.spec = new_chart.to_dict(format="vega") | ||
else: | ||
self.spec = new_chart.to_dict() | ||
self._selection_types = selection_types | ||
self._vl_selections = initial_vl_selections | ||
self._params = initial_params | ||
|
||
@traitlets.observe("_params") | ||
def _on_change_params(self, change): | ||
for param_name, value in change.new.items(): | ||
setattr(self.params, param_name, value) | ||
|
||
@traitlets.observe("_vl_selections") | ||
def _on_change_selections(self, change): | ||
""" | ||
Internal callback function that updates the JupyterChart's public | ||
selections traitlet in response to changes that the JavaScript logic | ||
makes to the internal _selections traitlet. | ||
""" | ||
for selection_name, selection_dict in change.new.items(): | ||
value = selection_dict["value"] | ||
store = selection_dict["store"] | ||
selection_type = self._selection_types[selection_name] | ||
if selection_type == "index": | ||
self.selections._set_value( | ||
selection_name, | ||
IndexSelection.from_vega(selection_name, signal=value, store=store), | ||
) | ||
elif selection_type == "point": | ||
self.selections._set_value( | ||
selection_name, | ||
PointSelection.from_vega(selection_name, signal=value, store=store), | ||
) | ||
elif selection_type == "interval": | ||
self.selections._set_value( | ||
selection_name, | ||
IntervalSelection.from_vega( | ||
selection_name, signal=value, store=store | ||
), | ||
) |
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.
You can import various lodash functions independently from
lodash-es
: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.
Oh cool. Does this have an impact on bundle size?
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.
Yes (and eliminates unnecessary imports). Should have suggested this originally (sorry!), see #3135