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 11 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
104 changes: 104 additions & 0 deletions altair/widget/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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
class Param:
name: str
value: Any


@dataclass
class SelectionParam:
name: str
value: Union[List[int], Dict[str, list]]
_store: List[Dict[str, Any]]

@classmethod
def from_vega(cls, name: str, value: dict, store: List[Dict[str, Any]]):
points = value.get("vlPoint", {}).get("or", None)
if points is not None:
# Transpose values e.g.
# from [{"a": 1, "b": "A"}, {"a": 2, "b": "B"}]
# to {"a": [1, 2], "b": ["A", "B"]}
selection_value: Union[dict, list] = {}
for point in points:
for k, v in point.items():
value.setdefault(k, []).append(v)

# _vgsid_ is one-based. subtract 1 to be zero-indexed
if list(value.keys()) == ["_vgsid_"]:
selection_value = [i - 1 for i in value["_vgsid_"]]
else:
selection_value = value

return SelectionParam(name=name, value=selection_value, _store=store)


class ChartWidget(anywidget.AnyWidget):
_esm = _here / "static" / "index.js"
_css = r"""
.vega-embed {
overflow: visible;
}
"""

chart = traitlets.Instance(TopLevelSpec)
spec = traitlets.Dict().tag(sync=True)
selections = traitlets.Dict()
params = 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)

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

params = getattr(new_chart, "params", [])
selection_watches = []
param_watches = []
if params is not alt.Undefined:
for param in new_chart.params:
select = getattr(param, "select", alt.Undefined)
if select != alt.Undefined:
selection_watches.append(param.name)
else:
param_watches.append(param.name)

# 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_watches = selection_watches
self._param_watches = param_watches

@traitlets.observe("_selections")
def change_selections(self, change):
new_selections = {}
for selection_name, selection_dict in change.new.items():
new_selections[selection_name] = SelectionParam.from_vega(
name=selection_name, **selection_dict
)
self.selections = new_selections

@traitlets.observe("_params")
def 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"]
artifact = "altair/widget/static/index.js"
Copy link
Contributor

Choose a reason for hiding this comment

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

should be “artifacts”, sorry for the typo in my suggestion


[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