Skip to content
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 24 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
775995a
Add ChartWidget based on AnyWidget
jonmmease Jul 15, 2023
ff29ea1
lodash to just-debounce-it for size reduction
jonmmease Jul 23, 2023
cf80f25
Rename ChartWidget to JupyterChart
jonmmease Jul 28, 2023
cce1579
Get rid of _param_watches (just use param keys)
jonmmease Jul 28, 2023
fa629d2
remove set_params method
jonmmease Jul 28, 2023
7a6ef41
rename params Dict traitlet to _params
jonmmease Jul 28, 2023
329860b
Store params in a traitlet object
jonmmease Jul 28, 2023
bf2e26d
Make selections prop a dynamic traitlet class
jonmmease Jul 28, 2023
d726038
Remove selection watches traitlet
jonmmease Jul 28, 2023
84a5503
black
jonmmease Jul 28, 2023
7418f46
Update tests
jonmmease Jul 28, 2023
31abc8d
Use runAsync to avoid race condition
jonmmease Jul 28, 2023
f2d7575
Skip vegafusion test when not installed
jonmmease Jul 28, 2023
fbb2932
Empty-Commit
jonmmease Jul 28, 2023
037b84f
Show errors the same was as the HTML renderer
jonmmease Jul 28, 2023
e87a3ee
mypy / ruff
jonmmease Jul 28, 2023
477b0c5
Update altair/jupyter/js/README.md
jonmmease Jul 29, 2023
cc13950
Update altair/jupyter/js/README.md
jonmmease Jul 29, 2023
9df3d52
Add kernel restart to message
jonmmease Jul 31, 2023
948a23d
Import JupyterChart in else
jonmmease Jul 31, 2023
d27e458
import from top-level
jonmmease Jul 31, 2023
0e574fa
mypy
jonmmease Jul 31, 2023
2e4a48e
Move non-widget selection logic to `util.selection`
jonmmease Aug 1, 2023
0fa4723
Use lodash's debounce for maxWait functionality
jonmmease Aug 1, 2023
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ Untitled*.ipynb
.vscode

# hatch, doc generation
data.json
data.json
3 changes: 3 additions & 0 deletions altair/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"CalculateTransform",
"Categorical",
"Chart",
"ChartWidget",
"Color",
"ColorDatum",
"ColorDef",
Expand Down Expand Up @@ -598,6 +599,7 @@
"vconcat",
"vegalite",
"vegalite_compilers",
"widget",
"with_property_setters",
]

Expand All @@ -607,6 +609,7 @@ def __dir__():


from .vegalite import *
from .widget import ChartWidget


def load_ipython_extension(ipython):
Expand Down
17 changes: 17 additions & 0 deletions altair/widget/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
try:
import anywidget # noqa: F401
from .chart_widget import ChartWidget
except ImportError:
# When anywidget isn't available, create stand-in ChartWidget class
# that raises an informative import error on construction. This
# way we can make ChartWidget available in the altair namespace
# when anywidget is not installed
class ChartWidget: # type: ignore
def __init__(self, *args, **kwargs):
raise ImportError(
"The Altair ChartWidget 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"
)
234 changes: 234 additions & 0 deletions altair/widget/chart_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import anywidget
import traitlets
import pathlib
from dataclasses import dataclass
from typing import Any, Dict, List

import altair as alt
from altair.utils._vegafusion_data import using_vegafusion
from altair.vegalite.v5.schema.core import TopLevelSpec

_here = pathlib.Path(__file__).parent


@dataclass(frozen=True, eq=True)
class IndexSelection:
"""
An IndexSelection represents the state of an Altair
point selection (as constructed by alt.selection_point())
when neither the fields nor encodings arguments are specified.

The value field is a list of zero-based indices into the
selected dataset.

Note: These indices only apply to the input DataFrame
for charts that do not include aggregations (e.g. a scatter chart).
"""

name: str
value: List[int]
store: List[Dict[str, Any]]


@dataclass(frozen=True, eq=True)
class PointSelection:
"""
A PointSelection represents the state of an Altair
point selection (as constructed by alt.selection_point())
when the fields or encodings arguments are specified.

The value field is a list of dicts of the form:
[{"dim1": 1, "dim2": "A"}, {"dim1": 2, "dim2": "BB"}]

where "dim1" and "dim2" are dataset columns and the dict values
correspond to the specific selected values.
"""

name: str
value: List[Dict[str, Any]]
store: List[Dict[str, Any]]


@dataclass(frozen=True, eq=True)
class IntervalSelection:
"""
An IntervalSelection represents the state of an Altair
interval selection (as constructed by alt.selection_interval()).

The value field is a dict of the form:
{"dim1": [0, 10], "dim2": ["A", "BB", "CCC"]}

where "dim1" and "dim2" are dataset columns and the dict values
correspond to the selected range.
"""

name: str
value: Dict[str, list]
store: List[Dict[str, Any]]


class ChartWidget(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)
selections = traitlets.Dict()
params = traitlets.Dict().tag(sync=True)
debounce_wait = traitlets.Float(default_value=10).tag(sync=True)

# Internal selection traitlets
_selection_types = traitlets.Dict()
_selection_watches = traitlets.List().tag(sync=True)
_selections = traitlets.Dict().tag(sync=True)

# Internal param traitlets
_param_watches = traitlets.List().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
"""
super().__init__(chart=chart, debounce_wait=debounce_wait, **kwargs)

def set_params(self, **kwargs: Any):
"""
Update one or more of a Chart's (non-selection) parameters.
The parameters that are eligible for update are stored in
the params property of the ChartWidget.

Parameters
----------
kwargs
Parameter name and value pairs
"""
updates = []
new_params = dict(self.params)
for name, value in kwargs.items():
if name not in self.params:
raise ValueError(f"No param named {name}")

updates.append(
{
"name": name,
"value": value,
}
)

new_params[name] = value

# Update params directly so that they are set immediately
# after this function returns (rather than waiting for round
# trip through front-end)
self.params = new_params

# Send param update message
self.send({"type": "setParams", "updates": updates})

@traitlets.observe("chart")
def _on_change_chart(self, change):
"""
Internal callback function that updates the ChartWidget's internal
state when the wrapped Chart instance changes
"""
new_chart = change.new

params = getattr(new_chart, "params", [])
selection_watches = []
selection_types = {}
param_watches = []
initial_params = {}
initial_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"
else:
selection_types[param.name] = "point"
elif select_type == "interval":
selection_types[param.name] = "interval"
else:
raise ValueError(f"Unexpected selection type {select.type}")
selection_watches.append(param.name)
initial_selections[param.name] = {"value": None, "store": []}
else:
param_watches.append(param.name)
clean_value = param.value if param.value != alt.Undefined else None
initial_params[param.name] = clean_value

# 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._selection_watches = selection_watches
self._selections = initial_selections
self.params = initial_params
self._param_watches = param_watches

@traitlets.observe("_selections")
def _on_change_selections(self, change):
"""
Internal callback function that updates the ChartWidget's public
selections traitlet in response to changes that the JavaScript logic
makes to the internal _selections traitlet.
"""
new_selections = {}
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":
if value is None:
indices = []
else:
points = value.get("vlPoint", {}).get("or", [])
indices = [p["_vgsid_"] - 1 for p in points]
new_selections[selection_name] = IndexSelection(
name=selection_name, value=indices, store=store
)
elif selection_type == "point":
if value is None:
points = []
else:
points = value.get("vlPoint", {}).get("or", [])
new_selections[selection_name] = PointSelection(
name=selection_name, value=points, store=store
)
elif selection_type == "interval":
if value is None:
value = {}
new_selections[selection_name] = IntervalSelection(
name=selection_name, value=value, store=store
)

self.selections = new_selections
2 changes: 2 additions & 0 deletions altair/widget/js/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# ChartWidget
This directory contains the JavaScript portion of the Altair `ChartWidget` Jupyter Widget. The `ChartWidget` is based on the [AnyWidget](https://anywidget.dev/) project.
70 changes: 70 additions & 0 deletions altair/widget/js/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import embed from "https://cdn.jsdelivr.net/npm/vega-embed@6/+esm";
import { debounce } from "https://cdn.jsdelivr.net/npm/[email protected]/lodash.js"
Copy link
Contributor

@manzt manzt Jul 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Know it’s not bundled, but lodash-es is a pretty large dep (98kb minified, https://bundlephobia.com/package/[email protected] ) for just one import.

The modern alternative I’ve been using is just-debounce-it from https://github.com/angus-c/just

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for the recommendation! Switched in ff29ea1


export async function render({ model, el }) {
let finalize;

const reembed = async () => {
if (finalize != null) {
finalize();
}

let spec = model.get("spec");
let api = await embed(el, spec);
finalize = api.finalize;

// Debounce config
const wait = model.get("debounce_wait") ?? 10;
const maxWait = wait;

const selectionWatches = model.get("_selection_watches");
const initialSelections = {};
for (const selectionName of selectionWatches) {
const selectionHandler = (_, value) => {
const newSelections = JSON.parse(JSON.stringify(model.get("_selections"))) || {};
const store = JSON.parse(JSON.stringify(api.view.data(`${selectionName}_store`)));

newSelections[selectionName] = {value, store};
model.set("_selections", newSelections);
model.save_changes();
};
api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait}));

initialSelections[selectionName] = {value: {}, store: []}
}
model.set("_selections", initialSelections);

const paramWatches = model.get("_param_watches");
const initialParams = {};
for (const paramName of paramWatches) {
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();

// Register custom message handler
model.on("msg:custom", msg => {
if (msg.type === "setParams") {
for (const update of msg.updates) {
api.view.signal(update.name, update.value);
}
api.view.run();
} else {
console.log(`Unexpected message type ${msg.type}`)
}
});
}

model.on('change:spec', reembed);
model.on('change:debounce_wait', reembed);
await reembed();
}
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ dev = [
"types-jsonschema",
"types-setuptools",
"pyarrow>=11",
"vegafusion[embed]"
"vegafusion[embed]",
"anywidget"
]
doc = [
"sphinx",
Expand All @@ -94,6 +95,7 @@ allow-direct-references = true

[tool.hatch.build]
include = ["/altair"]
artifacts = ["altair/widget/js/index.js"]

[tool.hatch.envs.default]
features = ["dev"]
Expand Down
Loading