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 offline ChartWidget based on AnyWidget #3108

Closed
wants to merge 16 commits into from
Closed
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,6 @@ Untitled*.ipynb
.vscode

# hatch, doc generation
data.json
data.json
/widget/node_modules/
/altair/widget/static/index.js
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ python -m pip install -e .[dev]
'[dev]' indicates that pip should also install the development requirements
which you can find in `pyproject.toml` (`[project.optional-dependencies]/dev`)

### Install Node.js and npm

Building Altair requires `npm`, which is distributed as part of Node.js. See the
official [Node.js installation instructions](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).

### Creating a Branch

Once your local environment is up-to-date, you can create a new git branch
Expand Down
177 changes: 177 additions & 0 deletions altair/widget/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import anywidget # type: ignore
Copy link
Contributor

Choose a reason for hiding this comment

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

oh interesting, are anywidget's types not working with mypy? maybe I forget a py.typed?

import traitlets
import pathlib
from dataclasses import dataclass
from typing import Any, Dict, List, Union

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 Param:
name: str
value: Any


@dataclass(frozen=True, eq=True)
class IndexSelectionParam:
name: str
value: List[int]
_store: List[Dict[str, Any]]


@dataclass(frozen=True, eq=True)
class PointSelectionParam:
name: str
value: List[Dict[str, Any]]
_store: List[Dict[str, Any]]


@dataclass(frozen=True, eq=True)
class IntervalSelectionParam:
name: str
value: Dict[str, list]
_store: List[Dict[str, Any]]


class ChartWidget(anywidget.AnyWidget):
_esm = _here / "static" / "index.js"
_css = r"""
.vega-embed {
/* Make sure action menu isn't cut off */
overflow: visible;
}
"""

chart = traitlets.Instance(TopLevelSpec)
spec = traitlets.Dict().tag(sync=True)
selections = traitlets.Dict()
params = traitlets.Dict()

debounce_wait = traitlets.Float(default_value=10).tag(sync=True)

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

_param_watches = traitlets.List().tag(sync=True)
_params = traitlets.Dict().tag(sync=True)

def set_params(self, *args: Param):
updates = []
for param in args:
if param.name not in self.params:
raise ValueError(f"No param named {param.name}")

updates.append({
"name": param.name,
"namespace": "signal",
"scope": [], # Assume params are top-level for now
"value": param.value,
})

# Update params directly so that they are set immediately
# after this function returns
new_params = dict(self._params)
for param in args:
new_params[param.name] = {"value": param.value}
self._params = new_params

self.send({
"type": "update",
"updates": updates
})

@traitlets.observe("chart")
def _on_change_chart(self, change):
new_chart = change.new

params = getattr(new_chart, "params", [])
selection_watches = []
selection_types = {}
param_watches = []
initial_params = dict()
initial_selections = dict()

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) and not 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)
initial_params[param.name] = {"value": param.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._param_watches = param_watches
self._params = initial_params

@traitlets.observe("_selections")
def _on_change_selections(self, change):
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] = IndexSelectionParam(
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] = PointSelectionParam(
name=selection_name, value=points, _store=store
)
elif selection_type == "interval":
if value is None:
value = {}
new_selections[selection_name] = IntervalSelectionParam(
name=selection_name, value=value, _store=store
)

self.selections = new_selections

@traitlets.observe("_params")
def _on_change_params(self, change):
new_params = {}
for param_name, param_dict in change.new.items():
new_params[param_name] = Param(name=param_name, **param_dict)
self.params = new_params
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ allow-direct-references = true

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

[tool.hatch.envs.default]
features = ["dev"]
Expand Down Expand Up @@ -241,3 +242,13 @@ module = [
"altair.vegalite.v5.schema.*"
]
ignore_errors = true

[tool.hatch.build.hooks.jupyter-builder]
build-function = "hatch_jupyter_builder.npm_builder"
ensured-targets = ["altair/widget/static/index.js"]
dependencies = ["hatch-jupyter-builder>=0.5.0"]

[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
npm = "npm"
build_cmd = "build"
path = "widget"
17 changes: 17 additions & 0 deletions widget/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# ChartWidget
This directory contains the JavaScript portion of the Altair `ChartWidget` Jupyter Widget. The `ChartWidget` is based on the [AnyWidget](https://anywidget.dev/) project.

# ChartWidget development instructions
First, make sure you have Node.js 18 installed.

Then build the JavaScript portion of `ChartWidget` widget in development mode:
```
cd widget/
npm install
npm run watch
```

This will write a file to `altair/widget/static/index.js`, which is specified as the `_esm` property of the `ChartWidget` Python class (located at `altair/widget/__init__.py`). Any changes to `widget/src/index.js` will automatically be recompiled as long as the `npm run watch` command is running.

# Release process
The JavaScript portion of the `ChartWidget` is automatically built in release mode when `hatch build` runs.
Loading