From 775995a02cbc4365c0ea8e84f8b4e03dcb451bfd Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 15 Jul 2023 16:39:23 -0400 Subject: [PATCH 01/24] Add ChartWidget based on AnyWidget --- .gitignore | 2 +- altair/__init__.py | 3 + altair/widget/__init__.py | 17 +++ altair/widget/chart_widget.py | 234 ++++++++++++++++++++++++++++++++ altair/widget/js/README.md | 2 + altair/widget/js/index.js | 70 ++++++++++ pyproject.toml | 4 +- tests/test_widget.py | 244 ++++++++++++++++++++++++++++++++++ 8 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 altair/widget/__init__.py create mode 100644 altair/widget/chart_widget.py create mode 100644 altair/widget/js/README.md create mode 100644 altair/widget/js/index.js create mode 100644 tests/test_widget.py diff --git a/.gitignore b/.gitignore index 787a3a21d..d72e70d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,4 @@ Untitled*.ipynb .vscode # hatch, doc generation -data.json \ No newline at end of file +data.json diff --git a/altair/__init__.py b/altair/__init__.py index 1a66239a5..75c375793 100644 --- a/altair/__init__.py +++ b/altair/__init__.py @@ -53,6 +53,7 @@ "CalculateTransform", "Categorical", "Chart", + "ChartWidget", "Color", "ColorDatum", "ColorDef", @@ -598,6 +599,7 @@ "vconcat", "vegalite", "vegalite_compilers", + "widget", "with_property_setters", ] @@ -607,6 +609,7 @@ def __dir__(): from .vegalite import * +from .widget import ChartWidget def load_ipython_extension(ipython): diff --git a/altair/widget/__init__.py b/altair/widget/__init__.py new file mode 100644 index 000000000..47dc38ed7 --- /dev/null +++ b/altair/widget/__init__.py @@ -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" + ) diff --git a/altair/widget/chart_widget.py b/altair/widget/chart_widget.py new file mode 100644 index 000000000..4ccfcfc10 --- /dev/null +++ b/altair/widget/chart_widget.py @@ -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 diff --git a/altair/widget/js/README.md b/altair/widget/js/README.md new file mode 100644 index 000000000..7a933d244 --- /dev/null +++ b/altair/widget/js/README.md @@ -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. diff --git a/altair/widget/js/index.js b/altair/widget/js/index.js new file mode 100644 index 000000000..c8da31a3f --- /dev/null +++ b/altair/widget/js/index.js @@ -0,0 +1,70 @@ +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; + + 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(); +} diff --git a/pyproject.toml b/pyproject.toml index c3da1bfd3..35e62b9e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,8 @@ dev = [ "types-jsonschema", "types-setuptools", "pyarrow>=11", - "vegafusion[embed]" + "vegafusion[embed]", + "anywidget" ] doc = [ "sphinx", @@ -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"] diff --git a/tests/test_widget.py b/tests/test_widget.py new file mode 100644 index 000000000..2aabb3379 --- /dev/null +++ b/tests/test_widget.py @@ -0,0 +1,244 @@ +import altair as alt +from altair.widget.chart_widget import IntervalSelection, IndexSelection, PointSelection +from vega_datasets import data +import pandas as pd +import pytest + + +@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +def test_chart_with_no_interactivity(transformer): + with alt.data_transformers.enable(transformer): + source = pd.DataFrame( + { + "a": ["A", "B", "C", "D", "E", "F", "G", "H", "I"], + "b": [28, 55, 43, 91, 81, 53, 19, 87, 52], + } + ) + + chart = alt.Chart(source).mark_bar().encode(x="a", y="b") + widget = alt.ChartWidget(chart) + + if transformer == "vegafusion": + assert widget.spec == chart.to_dict(format="vega") + else: + assert widget.spec == chart.to_dict() + + # There should be no params or selections initialized + assert len(widget.selections) == 0 + assert len(widget.params) == 0 + + +@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +def test_interval_selection_example(transformer): + with alt.data_transformers.enable(transformer): + source = data.cars() + brush = alt.selection_interval(name="interval") + + chart = ( + alt.Chart(source) + .mark_point() + .encode( + x="Horsepower:Q", + y="Miles_per_Gallon:Q", + color=alt.condition(brush, "Cylinders:O", alt.value("grey")), + ) + .add_params(brush) + ) + + widget = alt.ChartWidget(chart) + + if transformer == "vegafusion": + assert widget.spec == chart.to_dict(format="vega") + else: + assert widget.spec == chart.to_dict() + + # There should be one selection and zero params + assert len(widget.selections) == 1 + assert len(widget.params) == 0 + + # Check initial interval selection + selection = widget.selections["interval"] + assert isinstance(selection, IntervalSelection) + assert selection.value == {} + assert selection.store == [] + + # Simulate Vega signal update + store = [ + { + "unit": "", + "fields": [ + {"field": "Horsepower", "channel": "x", "type": "R"}, + {"field": "Miles_per_Gallon", "channel": "y", "type": "R"}, + ], + "values": [ + [40.0, 100], + [25, 30], + ], + } + ] + widget._selections = { + "interval": { + "value": { + "Horsepower": [40.0, 100], + "Miles_per_Gallon": [25, 30], + }, + "store": store, + } + } + + selection = widget.selections["interval"] + assert isinstance(selection, IntervalSelection) + assert selection.value == { + "Horsepower": [40.0, 100], + "Miles_per_Gallon": [25, 30], + } + assert selection.store == store + + +@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +def test_index_selection_example(transformer): + with alt.data_transformers.enable(transformer): + source = data.cars() + brush = alt.selection_point(name="index") + + chart = ( + alt.Chart(source) + .mark_point() + .encode( + x="Horsepower:Q", + y="Miles_per_Gallon:Q", + color=alt.condition(brush, "Cylinders:O", alt.value("grey")), + ) + .add_params(brush) + ) + + widget = alt.ChartWidget(chart) + + if transformer == "vegafusion": + assert widget.spec == chart.to_dict(format="vega") + else: + assert widget.spec == chart.to_dict() + + # There should be one selection and zero params + assert len(widget.selections) == 1 + assert len(widget.params) == 0 + + # Check initial interval selection + selection = widget.selections["index"] + assert isinstance(selection, IndexSelection) + assert selection.value == [] + assert selection.store == [] + + # Simulate Vega signal update + store = [ + {"unit": "", "_vgsid_": 220}, + {"unit": "", "_vgsid_": 330}, + {"unit": "", "_vgsid_": 341}, + ] + + widget._selections = { + "index": { + "value": { + "_vgsid_": "Set(220,330,341)", + "vlPoint": { + "or": [{"_vgsid_": 220}, {"_vgsid_": 330}, {"_vgsid_": 341}] + }, + }, + "store": store, + } + } + + selection = widget.selections["index"] + assert isinstance(selection, IndexSelection) + assert selection.value == [219, 329, 340] + assert selection.store == store + + +@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +def test_point_selection(transformer): + with alt.data_transformers.enable(transformer): + source = data.cars() + brush = alt.selection_point(name="point", encodings=["color"], bind="legend") + + chart = ( + alt.Chart(source) + .mark_point() + .encode( + x="Horsepower:Q", + y="Miles_per_Gallon:Q", + color=alt.condition(brush, "Cylinders:O", alt.value("grey")), + ) + .add_params(brush) + ) + + widget = alt.ChartWidget(chart) + + if transformer == "vegafusion": + assert widget.spec == chart.to_dict(format="vega") + else: + assert widget.spec == chart.to_dict() + + # There should be one selection and zero params + assert len(widget.selections) == 1 + assert len(widget.params) == 0 + + # Check initial interval selection + selection = widget.selections["point"] + assert isinstance(selection, PointSelection) + assert selection.value == [] + assert selection.store == [] + + # Simulate Vega signal update + store = [ + { + "fields": [{"field": "Cylinders", "channel": "color", "type": "E"}], + "values": [4], + }, + { + "fields": [{"field": "Cylinders", "channel": "color", "type": "E"}], + "values": [5], + }, + ] + + widget._selections = { + "point": { + "value": { + "Cylinders": [4, 5], + "vlPoint": {"or": [{"Cylinders": 4}, {"Cylinders": 5}]}, + }, + "store": store, + } + } + + selection = widget.selections["point"] + assert isinstance(selection, PointSelection) + assert selection.value == [{"Cylinders": 4}, {"Cylinders": 5}] + assert selection.store == store + + +@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +def test_param_updates(transformer): + with alt.data_transformers.enable(transformer): + source = data.cars() + size_param = alt.param( + name="size", value=10, bind=alt.binding_range(min=1, max=100) + ) + chart = ( + alt.Chart(source) + .mark_point() + .encode(x="Horsepower:Q", y="Miles_per_Gallon:Q", size=size_param) + .add_params(size_param) + ) + + widget = alt.ChartWidget(chart) + + # There should be one param and zero selections + assert len(widget.selections) == 0 + assert len(widget.params) == 1 + + # Initial value should match what was provided + assert widget.params["size"] == 10 + + # Update param from python + widget.set_params(size=50) + assert widget.params["size"] == 50 From ff29ea1ef5af054476a1b6320f9cdb1629a390dc Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sun, 23 Jul 2023 07:37:38 -0400 Subject: [PATCH 02/24] lodash to just-debounce-it for size reduction --- altair/widget/js/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/altair/widget/js/index.js b/altair/widget/js/index.js index c8da31a3f..d91a2b7e0 100644 --- a/altair/widget/js/index.js +++ b/altair/widget/js/index.js @@ -1,5 +1,5 @@ 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" +import debounce from 'https://cdn.jsdelivr.net/npm/just-debounce-it@3.2.0/+esm' export async function render({ model, el }) { let finalize; @@ -15,7 +15,6 @@ export async function render({ model, el }) { // Debounce config const wait = model.get("debounce_wait") ?? 10; - const maxWait = wait; const selectionWatches = model.get("_selection_watches"); const initialSelections = {}; @@ -28,7 +27,7 @@ export async function render({ model, el }) { model.set("_selections", newSelections); model.save_changes(); }; - api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait})); + api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, true)); initialSelections[selectionName] = {value: {}, store: []} } @@ -43,7 +42,7 @@ export async function render({ model, el }) { model.set("params", newParams); model.save_changes(); }; - api.view.addSignalListener(paramName, debounce(paramHandler, wait, {maxWait})); + api.view.addSignalListener(paramName, debounce(paramHandler, wait, true)); initialParams[paramName] = api.view.signal(paramName) ?? null } From cf80f2576e157a478e990ab7507890988d619fca Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 08:01:19 -0400 Subject: [PATCH 03/24] Rename ChartWidget to JupyterChart --- altair/__init__.py | 6 +++--- altair/{widget => jupyter}/__init__.py | 10 +++++----- altair/{widget => jupyter}/js/README.md | 0 altair/{widget => jupyter}/js/index.js | 0 .../chart_widget.py => jupyter/jupyter_chart.py} | 8 ++++---- pyproject.toml | 2 +- tests/{test_widget.py => test_jupyter_chart.py} | 16 ++++++++++------ 7 files changed, 23 insertions(+), 19 deletions(-) rename altair/{widget => jupyter}/__init__.py (61%) rename altair/{widget => jupyter}/js/README.md (100%) rename altair/{widget => jupyter}/js/index.js (100%) rename altair/{widget/chart_widget.py => jupyter/jupyter_chart.py} (97%) rename tests/{test_widget.py => test_jupyter_chart.py} (95%) diff --git a/altair/__init__.py b/altair/__init__.py index 75c375793..594cb22d7 100644 --- a/altair/__init__.py +++ b/altair/__init__.py @@ -53,7 +53,6 @@ "CalculateTransform", "Categorical", "Chart", - "ChartWidget", "Color", "ColorDatum", "ColorDef", @@ -235,6 +234,7 @@ "JoinAggregateFieldDef", "JoinAggregateTransform", "JsonDataFormat", + "JupyterChart", "Key", "LabelOverlap", "LatLongDef", @@ -570,6 +570,7 @@ "expr", "graticule", "hconcat", + "jupyter", "layer", "limit_rows", "load_ipython_extension", @@ -599,7 +600,6 @@ "vconcat", "vegalite", "vegalite_compilers", - "widget", "with_property_setters", ] @@ -609,7 +609,7 @@ def __dir__(): from .vegalite import * -from .widget import ChartWidget +from .jupyter import JupyterChart def load_ipython_extension(ipython): diff --git a/altair/widget/__init__.py b/altair/jupyter/__init__.py similarity index 61% rename from altair/widget/__init__.py rename to altair/jupyter/__init__.py index 47dc38ed7..488d7c560 100644 --- a/altair/widget/__init__.py +++ b/altair/jupyter/__init__.py @@ -1,15 +1,15 @@ try: import anywidget # noqa: F401 - from .chart_widget import ChartWidget + from .jupyter_chart import JupyterChart except ImportError: - # When anywidget isn't available, create stand-in ChartWidget class + # When anywidget isn't available, create stand-in JupyterChart class # that raises an informative import error on construction. This - # way we can make ChartWidget available in the altair namespace + # way we can make JupyterChart available in the altair namespace # when anywidget is not installed - class ChartWidget: # type: ignore + class JupyterChart: # type: ignore def __init__(self, *args, **kwargs): raise ImportError( - "The Altair ChartWidget requires the anywidget \n" + "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" diff --git a/altair/widget/js/README.md b/altair/jupyter/js/README.md similarity index 100% rename from altair/widget/js/README.md rename to altair/jupyter/js/README.md diff --git a/altair/widget/js/index.js b/altair/jupyter/js/index.js similarity index 100% rename from altair/widget/js/index.js rename to altair/jupyter/js/index.js diff --git a/altair/widget/chart_widget.py b/altair/jupyter/jupyter_chart.py similarity index 97% rename from altair/widget/chart_widget.py rename to altair/jupyter/jupyter_chart.py index 4ccfcfc10..635bf5aae 100644 --- a/altair/widget/chart_widget.py +++ b/altair/jupyter/jupyter_chart.py @@ -67,7 +67,7 @@ class IntervalSelection: store: List[Dict[str, Any]] -class ChartWidget(anywidget.AnyWidget): +class JupyterChart(anywidget.AnyWidget): _esm = _here / "js" / "index.js" _css = r""" .vega-embed { @@ -109,7 +109,7 @@ 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. + the params property of the JupyterChart. Parameters ---------- @@ -142,7 +142,7 @@ def set_params(self, **kwargs: Any): @traitlets.observe("chart") def _on_change_chart(self, change): """ - Internal callback function that updates the ChartWidget's internal + Internal callback function that updates the JupyterChart's internal state when the wrapped Chart instance changes """ new_chart = change.new @@ -198,7 +198,7 @@ def _on_change_chart(self, change): @traitlets.observe("_selections") def _on_change_selections(self, change): """ - Internal callback function that updates the ChartWidget's public + 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. """ diff --git a/pyproject.toml b/pyproject.toml index 35e62b9e2..87c693455 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ allow-direct-references = true [tool.hatch.build] include = ["/altair"] -artifacts = ["altair/widget/js/index.js"] +artifacts = ["altair/jupyter/js/index.js"] [tool.hatch.envs.default] features = ["dev"] diff --git a/tests/test_widget.py b/tests/test_jupyter_chart.py similarity index 95% rename from tests/test_widget.py rename to tests/test_jupyter_chart.py index 2aabb3379..ed7f8c781 100644 --- a/tests/test_widget.py +++ b/tests/test_jupyter_chart.py @@ -1,5 +1,9 @@ import altair as alt -from altair.widget.chart_widget import IntervalSelection, IndexSelection, PointSelection +from altair.jupyter.jupyter_chart import ( + IntervalSelection, + IndexSelection, + PointSelection, +) from vega_datasets import data import pandas as pd import pytest @@ -16,7 +20,7 @@ def test_chart_with_no_interactivity(transformer): ) chart = alt.Chart(source).mark_bar().encode(x="a", y="b") - widget = alt.ChartWidget(chart) + widget = alt.JupyterChart(chart) if transformer == "vegafusion": assert widget.spec == chart.to_dict(format="vega") @@ -45,7 +49,7 @@ def test_interval_selection_example(transformer): .add_params(brush) ) - widget = alt.ChartWidget(chart) + widget = alt.JupyterChart(chart) if transformer == "vegafusion": assert widget.spec == chart.to_dict(format="vega") @@ -112,7 +116,7 @@ def test_index_selection_example(transformer): .add_params(brush) ) - widget = alt.ChartWidget(chart) + widget = alt.JupyterChart(chart) if transformer == "vegafusion": assert widget.spec == chart.to_dict(format="vega") @@ -171,7 +175,7 @@ def test_point_selection(transformer): .add_params(brush) ) - widget = alt.ChartWidget(chart) + widget = alt.JupyterChart(chart) if transformer == "vegafusion": assert widget.spec == chart.to_dict(format="vega") @@ -230,7 +234,7 @@ def test_param_updates(transformer): .add_params(size_param) ) - widget = alt.ChartWidget(chart) + widget = alt.JupyterChart(chart) # There should be one param and zero selections assert len(widget.selections) == 0 From cce15797a79aedbd39389d418be9c7b38c322464 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 08:55:55 -0400 Subject: [PATCH 04/24] Get rid of _param_watches (just use param keys) --- altair/jupyter/js/index.js | 3 +-- altair/jupyter/jupyter_chart.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index d91a2b7e0..18040c3cc 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -33,9 +33,8 @@ export async function render({ model, el }) { } model.set("_selections", initialSelections); - const paramWatches = model.get("_param_watches"); const initialParams = {}; - for (const paramName of paramWatches) { + for (const paramName of Object.keys(model.get("params"))) { const paramHandler = (_, value) => { const newParams = JSON.parse(JSON.stringify(model.get("params"))) || {}; newParams[paramName] = value; diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 635bf5aae..650a2f0fc 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -150,7 +150,6 @@ def _on_change_chart(self, change): params = getattr(new_chart, "params", []) selection_watches = [] selection_types = {} - param_watches = [] initial_params = {} initial_selections = {} @@ -179,7 +178,6 @@ def _on_change_chart(self, change): 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 @@ -193,7 +191,6 @@ def _on_change_chart(self, change): 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): From fa629d224ce26e20e60f2889037be6b1aa46a280 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 09:01:22 -0400 Subject: [PATCH 05/24] remove set_params method --- altair/jupyter/js/index.js | 14 +++++--------- altair/jupyter/jupyter_chart.py | 34 --------------------------------- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 18040c3cc..6c6fdf443 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -49,16 +49,12 @@ export async function render({ model, el }) { 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}`) + // Param change callback + model.on('change:params', (new_params) => { + for (const [param, value] of Object.entries(new_params.changed.params)) { + api.view.signal(param, value); } + api.view.run(); }); } diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 650a2f0fc..5fb0dc671 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -105,40 +105,6 @@ def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): """ 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 JupyterChart. - - 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): """ From 7a6ef41a801c43d390e57d7fa43e2ea6fed91ea5 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 09:06:21 -0400 Subject: [PATCH 06/24] rename params Dict traitlet to _params --- altair/jupyter/js/index.js | 13 ++++++------- altair/jupyter/jupyter_chart.py | 5 ++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 6c6fdf443..4d30f3727 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -34,24 +34,23 @@ export async function render({ model, el }) { model.set("_selections", initialSelections); const initialParams = {}; - for (const paramName of Object.keys(model.get("params"))) { + for (const paramName of Object.keys(model.get("_params"))) { const paramHandler = (_, value) => { - const newParams = JSON.parse(JSON.stringify(model.get("params"))) || {}; + const newParams = JSON.parse(JSON.stringify(model.get("_params"))) || {}; newParams[paramName] = value; - model.set("params", newParams); + model.set("_params", newParams); model.save_changes(); }; api.view.addSignalListener(paramName, debounce(paramHandler, wait, true)); initialParams[paramName] = api.view.signal(paramName) ?? null } - model.set("params", initialParams); - + model.set("_params", initialParams); model.save_changes(); // Param change callback - model.on('change:params', (new_params) => { - for (const [param, value] of Object.entries(new_params.changed.params)) { + model.on('change:_params', (new_params) => { + for (const [param, value] of Object.entries(new_params.changed._params)) { api.view.signal(param, value); } api.view.run(); diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 5fb0dc671..79ab9090e 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -80,7 +80,6 @@ class JupyterChart(anywidget.AnyWidget): 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 @@ -89,7 +88,7 @@ class JupyterChart(anywidget.AnyWidget): _selections = traitlets.Dict().tag(sync=True) # Internal param traitlets - _param_watches = traitlets.List().tag(sync=True) + _params = traitlets.Dict().tag(sync=True) def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): """ @@ -156,7 +155,7 @@ def _on_change_chart(self, change): self._selection_types = selection_types self._selection_watches = selection_watches self._selections = initial_selections - self.params = initial_params + self._params = initial_params @traitlets.observe("_selections") def _on_change_selections(self, change): From 329860bc5d96813524af6e7adc4461151ee5db09 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 09:29:05 -0400 Subject: [PATCH 07/24] Store params in a traitlet object --- altair/jupyter/jupyter_chart.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 79ab9090e..eab4d8f34 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -11,6 +11,37 @@ _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()})" + + @dataclass(frozen=True, eq=True) class IndexSelection: """ @@ -102,6 +133,7 @@ def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): debounce_wait: int Debouncing wait time in milliseconds """ + self.params = Params({}) super().__init__(chart=chart, debounce_wait=debounce_wait, **kwargs) @traitlets.observe("chart") @@ -146,6 +178,15 @@ def _on_change_chart(self, change): clean_value = param.value if param.value != alt.Undefined else None initial_params[param.name] = clean_value + 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) + # Update properties all together with self.hold_sync(): if using_vegafusion(): @@ -157,6 +198,11 @@ def _on_change_chart(self, change): self._selections = initial_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("_selections") def _on_change_selections(self, change): """ From bf2e26d367eed91a09f5c8aca3c8def45f3a0277 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:05:57 -0400 Subject: [PATCH 08/24] Make selections prop a dynamic traitlet class --- altair/jupyter/js/index.js | 6 +-- altair/jupyter/jupyter_chart.py | 85 +++++++++++++++++++++++++++------ tests/test_jupyter_chart.py | 6 +-- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 4d30f3727..2c6147183 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -20,18 +20,18 @@ export async function render({ model, el }) { const initialSelections = {}; for (const selectionName of selectionWatches) { const selectionHandler = (_, value) => { - const newSelections = JSON.parse(JSON.stringify(model.get("_selections"))) || {}; + 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("_selections", newSelections); + model.set("_vl_selections", newSelections); model.save_changes(); }; api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, true)); initialSelections[selectionName] = {value: {}, store: []} } - model.set("_selections", initialSelections); + model.set("_vl_selections", initialSelections); const initialParams = {}; for (const paramName of Object.keys(model.get("_params"))) { diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index eab4d8f34..0935d5740 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -42,6 +42,53 @@ 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) + + @dataclass(frozen=True, eq=True) class IndexSelection: """ @@ -110,13 +157,12 @@ class JupyterChart(anywidget.AnyWidget): # Public traitlets chart = traitlets.Instance(TopLevelSpec) spec = traitlets.Dict().tag(sync=True) - selections = traitlets.Dict() 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) + _vl_selections = traitlets.Dict().tag(sync=True) # Internal param traitlets _params = traitlets.Dict().tag(sync=True) @@ -134,6 +180,7 @@ def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): Debouncing wait time in milliseconds """ self.params = Params({}) + self.selections = Selections({}) super().__init__(chart=chart, debounce_wait=debounce_wait, **kwargs) @traitlets.observe("chart") @@ -148,7 +195,8 @@ def _on_change_chart(self, change): selection_watches = [] selection_types = {} initial_params = {} - initial_selections = {} + initial_vl_selections = {} + empty_selections = {} if params is not alt.Undefined: for param in new_chart.params: @@ -166,18 +214,22 @@ def _on_change_chart(self, change): # 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_selections[param.name] = {"value": None, "store": []} + 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): @@ -187,6 +239,9 @@ def on_param_traitlet_changed(param_change): 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(): @@ -195,7 +250,7 @@ def on_param_traitlet_changed(param_change): self.spec = new_chart.to_dict() self._selection_types = selection_types self._selection_watches = selection_watches - self._selections = initial_selections + self._vl_selections = initial_vl_selections self._params = initial_params @traitlets.observe("_params") @@ -203,14 +258,13 @@ def _on_change_params(self, change): for param_name, value in change.new.items(): setattr(self.params, param_name, value) - @traitlets.observe("_selections") + @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. """ - new_selections = {} for selection_name, selection_dict in change.new.items(): value = selection_dict["value"] store = selection_dict["store"] @@ -221,22 +275,23 @@ def _on_change_selections(self, change): else: points = value.get("vlPoint", {}).get("or", []) indices = [p["_vgsid_"] - 1 for p in points] - new_selections[selection_name] = IndexSelection( + + self.selections._set_value(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( + + self.selections._set_value(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 + self.selections._set_value(selection_name, IntervalSelection( + name=selection_name, value=value, store=store + )) diff --git a/tests/test_jupyter_chart.py b/tests/test_jupyter_chart.py index ed7f8c781..02924115d 100644 --- a/tests/test_jupyter_chart.py +++ b/tests/test_jupyter_chart.py @@ -80,7 +80,7 @@ def test_interval_selection_example(transformer): ], } ] - widget._selections = { + widget._vl_selections = { "interval": { "value": { "Horsepower": [40.0, 100], @@ -140,7 +140,7 @@ def test_index_selection_example(transformer): {"unit": "", "_vgsid_": 341}, ] - widget._selections = { + widget._vl_selections = { "index": { "value": { "_vgsid_": "Set(220,330,341)", @@ -204,7 +204,7 @@ def test_point_selection(transformer): }, ] - widget._selections = { + widget._vl_selections = { "point": { "value": { "Cylinders": [4, 5], From d726038032406b8cf1b5f1121b380720b163d3ab Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:08:08 -0400 Subject: [PATCH 09/24] Remove selection watches traitlet --- altair/jupyter/js/index.js | 3 +-- altair/jupyter/jupyter_chart.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 2c6147183..d8b56fd0b 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -16,9 +16,8 @@ export async function render({ model, el }) { // Debounce config const wait = model.get("debounce_wait") ?? 10; - const selectionWatches = model.get("_selection_watches"); const initialSelections = {}; - for (const selectionName of selectionWatches) { + 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`))); diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 0935d5740..aac89ebb6 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -161,7 +161,6 @@ class JupyterChart(anywidget.AnyWidget): # Internal selection traitlets _selection_types = traitlets.Dict() - _selection_watches = traitlets.List().tag(sync=True) _vl_selections = traitlets.Dict().tag(sync=True) # Internal param traitlets @@ -249,7 +248,6 @@ def on_param_traitlet_changed(param_change): else: self.spec = new_chart.to_dict() self._selection_types = selection_types - self._selection_watches = selection_watches self._vl_selections = initial_vl_selections self._params = initial_params From 84a55033045f8ef3ac7428dd6eda647139dd645d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:10:28 -0400 Subject: [PATCH 10/24] black --- altair/jupyter/jupyter_chart.py | 39 +++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index aac89ebb6..9cac0f66e 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -15,6 +15,7 @@ class Params(traitlets.HasTraits): """ Traitlet class storing a JupyterChart's params """ + def __init__(self, trait_values): super().__init__() @@ -46,6 +47,7 @@ class Selections(traitlets.HasTraits): """ Traitlet class storing a JupyterChart's selections """ + def __init__(self, trait_values): super().__init__() @@ -76,8 +78,8 @@ 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']) + 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']}" @@ -213,13 +215,19 @@ def _on_change_chart(self, change): # 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=[]) + 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=[]) + 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=[]) + empty_selections[param.name] = IntervalSelection( + name=param.name, value={}, store=[] + ) else: raise ValueError(f"Unexpected selection type {select.type}") selection_watches.append(param.name) @@ -274,22 +282,25 @@ def _on_change_selections(self, change): points = value.get("vlPoint", {}).get("or", []) indices = [p["_vgsid_"] - 1 for p in points] - self.selections._set_value(selection_name, IndexSelection( - name=selection_name, value=indices, store=store - )) + self.selections._set_value( + 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", []) - self.selections._set_value(selection_name, PointSelection( - name=selection_name, value=points, store=store - )) + self.selections._set_value( + selection_name, + PointSelection(name=selection_name, value=points, store=store), + ) elif selection_type == "interval": if value is None: value = {} - self.selections._set_value(selection_name, IntervalSelection( - name=selection_name, value=value, store=store - )) + self.selections._set_value( + selection_name, + IntervalSelection(name=selection_name, value=value, store=store), + ) From 7418f4669ec63e953323be3d720488323b16feeb Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:19:34 -0400 Subject: [PATCH 11/24] Update tests --- tests/test_jupyter_chart.py | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/test_jupyter_chart.py b/tests/test_jupyter_chart.py index 02924115d..e76dc487f 100644 --- a/tests/test_jupyter_chart.py +++ b/tests/test_jupyter_chart.py @@ -28,8 +28,8 @@ def test_chart_with_no_interactivity(transformer): assert widget.spec == chart.to_dict() # There should be no params or selections initialized - assert len(widget.selections) == 0 - assert len(widget.params) == 0 + assert len(widget.selections.trait_values()) == 0 + assert len(widget.params.trait_values()) == 0 @pytest.mark.parametrize("transformer", ["default", "vegafusion"]) @@ -57,11 +57,11 @@ def test_interval_selection_example(transformer): assert widget.spec == chart.to_dict() # There should be one selection and zero params - assert len(widget.selections) == 1 - assert len(widget.params) == 0 + assert len(widget.selections.trait_values()) == 1 + assert len(widget.params.trait_values()) == 0 # Check initial interval selection - selection = widget.selections["interval"] + selection = widget.selections.interval assert isinstance(selection, IntervalSelection) assert selection.value == {} assert selection.store == [] @@ -90,7 +90,7 @@ def test_interval_selection_example(transformer): } } - selection = widget.selections["interval"] + selection = widget.selections.interval assert isinstance(selection, IntervalSelection) assert selection.value == { "Horsepower": [40.0, 100], @@ -124,11 +124,11 @@ def test_index_selection_example(transformer): assert widget.spec == chart.to_dict() # There should be one selection and zero params - assert len(widget.selections) == 1 - assert len(widget.params) == 0 + assert len(widget.selections.trait_values()) == 1 + assert len(widget.params.trait_values()) == 0 # Check initial interval selection - selection = widget.selections["index"] + selection = widget.selections.index assert isinstance(selection, IndexSelection) assert selection.value == [] assert selection.store == [] @@ -152,7 +152,7 @@ def test_index_selection_example(transformer): } } - selection = widget.selections["index"] + selection = widget.selections.index assert isinstance(selection, IndexSelection) assert selection.value == [219, 329, 340] assert selection.store == store @@ -183,11 +183,11 @@ def test_point_selection(transformer): assert widget.spec == chart.to_dict() # There should be one selection and zero params - assert len(widget.selections) == 1 - assert len(widget.params) == 0 + assert len(widget.selections.trait_values()) == 1 + assert len(widget.params.trait_values()) == 0 # Check initial interval selection - selection = widget.selections["point"] + selection = widget.selections.point assert isinstance(selection, PointSelection) assert selection.value == [] assert selection.store == [] @@ -214,7 +214,7 @@ def test_point_selection(transformer): } } - selection = widget.selections["point"] + selection = widget.selections.point assert isinstance(selection, PointSelection) assert selection.value == [{"Cylinders": 4}, {"Cylinders": 5}] assert selection.store == store @@ -237,12 +237,12 @@ def test_param_updates(transformer): widget = alt.JupyterChart(chart) # There should be one param and zero selections - assert len(widget.selections) == 0 - assert len(widget.params) == 1 + assert len(widget.selections.trait_values()) == 0 + assert len(widget.params.trait_values()) == 1 # Initial value should match what was provided - assert widget.params["size"] == 10 + assert widget.params.size == 10 # Update param from python - widget.set_params(size=50) - assert widget.params["size"] == 50 + widget.params.size = 50 + assert widget.params.size == 50 From 31abc8d5e875c16313a32e86b224db0c6d9e9ec9 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:52:52 -0400 Subject: [PATCH 12/24] Use runAsync to avoid race condition --- altair/jupyter/js/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index d8b56fd0b..d4564401e 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -48,11 +48,11 @@ export async function render({ model, el }) { model.save_changes(); // Param change callback - model.on('change:_params', (new_params) => { + model.on('change:_params', async (new_params) => { for (const [param, value] of Object.entries(new_params.changed._params)) { api.view.signal(param, value); } - api.view.run(); + await api.view.runAsync(); }); } From f2d757589a25f33deddb3b71e6d3da350dc6d686 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 10:59:47 -0400 Subject: [PATCH 13/24] Skip vegafusion test when not installed --- tests/test_jupyter_chart.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_jupyter_chart.py b/tests/test_jupyter_chart.py index e76dc487f..ad5683ba2 100644 --- a/tests/test_jupyter_chart.py +++ b/tests/test_jupyter_chart.py @@ -8,8 +8,15 @@ import pandas as pd import pytest +try: + import vegafusion -@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) + transformers = ["default", "vegafusion"] +except ImportError: + transformers = ["default"] + + +@pytest.mark.parametrize("transformer", transformers) def test_chart_with_no_interactivity(transformer): with alt.data_transformers.enable(transformer): source = pd.DataFrame( @@ -32,7 +39,7 @@ def test_chart_with_no_interactivity(transformer): assert len(widget.params.trait_values()) == 0 -@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +@pytest.mark.parametrize("transformer", transformers) def test_interval_selection_example(transformer): with alt.data_transformers.enable(transformer): source = data.cars() @@ -99,7 +106,7 @@ def test_interval_selection_example(transformer): assert selection.store == store -@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +@pytest.mark.parametrize("transformer", transformers) def test_index_selection_example(transformer): with alt.data_transformers.enable(transformer): source = data.cars() @@ -158,7 +165,7 @@ def test_index_selection_example(transformer): assert selection.store == store -@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +@pytest.mark.parametrize("transformer", transformers) def test_point_selection(transformer): with alt.data_transformers.enable(transformer): source = data.cars() @@ -220,7 +227,7 @@ def test_point_selection(transformer): assert selection.store == store -@pytest.mark.parametrize("transformer", ["default", "vegafusion"]) +@pytest.mark.parametrize("transformer", transformers) def test_param_updates(transformer): with alt.data_transformers.enable(transformer): source = data.cars() From fbb293262c43f89006b8f48631056e239407392c Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 11:01:14 -0400 Subject: [PATCH 14/24] Empty-Commit From 037b84fc231c2446240631d414a897c22bd6fc5d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 11:17:33 -0400 Subject: [PATCH 15/24] Show errors the same was as the HTML renderer --- altair/jupyter/js/index.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index d4564401e..fd71cd282 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -4,13 +4,30 @@ import debounce from 'https://cdn.jsdelivr.net/npm/just-debounce-it@3.2.0/+esm' export async function render({ model, el }) { let finalize; + function showError(error){ + el.innerHTML = ( + '
' + + '

JavaScript Error: ' + error.message + '

' + + "

This usually means there's a typo in your chart specification. " + + "See the javascript console for the full traceback.

" + + '
' + ); + } + const reembed = async () => { if (finalize != null) { finalize(); } let spec = model.get("spec"); - let api = await embed(el, spec); + let api; + try { + api = await embed(el, spec); + } catch (error) { + showError(error) + return; + } + finalize = api.finalize; // Debounce config From e87a3eed75aa8a95b007f5a87823732a8295ef42 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 28 Jul 2023 11:21:54 -0400 Subject: [PATCH 16/24] mypy / ruff --- tests/test_jupyter_chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_jupyter_chart.py b/tests/test_jupyter_chart.py index ad5683ba2..3eebcc9dc 100644 --- a/tests/test_jupyter_chart.py +++ b/tests/test_jupyter_chart.py @@ -9,7 +9,7 @@ import pytest try: - import vegafusion + import vegafusion # type: ignore # noqa: F401 transformers = ["default", "vegafusion"] except ImportError: From 477b0c56c1c0809640cc23ee1bd5474116ac4ab4 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 29 Jul 2023 10:02:35 -0400 Subject: [PATCH 17/24] Update altair/jupyter/js/README.md ChartWidget -> JupyterChart Co-authored-by: Mattijn van Hoek --- altair/jupyter/js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/altair/jupyter/js/README.md b/altair/jupyter/js/README.md index 7a933d244..9958ce0c9 100644 --- a/altair/jupyter/js/README.md +++ b/altair/jupyter/js/README.md @@ -1,2 +1,2 @@ -# ChartWidget +# JupyterChart This directory contains the JavaScript portion of the Altair `ChartWidget` Jupyter Widget. The `ChartWidget` is based on the [AnyWidget](https://anywidget.dev/) project. From cc139500e61b87c81c0ddec4db0fa0065204418e Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 29 Jul 2023 10:02:52 -0400 Subject: [PATCH 18/24] Update altair/jupyter/js/README.md ChartWidget -> JupyterChart Co-authored-by: Mattijn van Hoek --- altair/jupyter/js/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/altair/jupyter/js/README.md b/altair/jupyter/js/README.md index 9958ce0c9..f1ec54589 100644 --- a/altair/jupyter/js/README.md +++ b/altair/jupyter/js/README.md @@ -1,2 +1,2 @@ # JupyterChart -This directory contains the JavaScript portion of the Altair `ChartWidget` Jupyter Widget. The `ChartWidget` is based on the [AnyWidget](https://anywidget.dev/) project. +This directory contains the JavaScript portion of the Altair `JupyterChart`. The `JupyterChart` is based on the [AnyWidget](https://anywidget.dev/) project. From 9df3d525e5d052e82e032c9c48c4cff5139c6eb2 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 31 Jul 2023 12:44:53 -0400 Subject: [PATCH 19/24] Add kernel restart to message --- altair/jupyter/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/altair/jupyter/__init__.py b/altair/jupyter/__init__.py index 488d7c560..d029d0c6e 100644 --- a/altair/jupyter/__init__.py +++ b/altair/jupyter/__init__.py @@ -13,5 +13,6 @@ def __init__(self, *args, **kwargs): "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" + " conda install -c conda-forge anywidget\n" + "Afterwards, you will need to restart your Python kernel." ) From 948a23d061fa0a4ac371937aa0d93a6f8d38b925 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 31 Jul 2023 12:45:23 -0400 Subject: [PATCH 20/24] Import JupyterChart in else --- altair/jupyter/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/altair/jupyter/__init__.py b/altair/jupyter/__init__.py index d029d0c6e..ad19efada 100644 --- a/altair/jupyter/__init__.py +++ b/altair/jupyter/__init__.py @@ -1,6 +1,5 @@ try: import anywidget # noqa: F401 - from .jupyter_chart import JupyterChart except ImportError: # When anywidget isn't available, create stand-in JupyterChart class # that raises an informative import error on construction. This @@ -16,3 +15,5 @@ def __init__(self, *args, **kwargs): " conda install -c conda-forge anywidget\n" "Afterwards, you will need to restart your Python kernel." ) +else: + from .jupyter_chart import JupyterChart From d27e458df18edf322110ee67291ee6d2b62767ee Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 31 Jul 2023 12:47:33 -0400 Subject: [PATCH 21/24] import from top-level --- altair/jupyter/jupyter_chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 9cac0f66e..bfd64a4f1 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -6,7 +6,7 @@ import altair as alt from altair.utils._vegafusion_data import using_vegafusion -from altair.vegalite.v5.schema.core import TopLevelSpec +from altair import TopLevelSpec _here = pathlib.Path(__file__).parent From 0e574fa99baa1b6f46b3a3bdedc43418be47925f Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Mon, 31 Jul 2023 12:47:43 -0400 Subject: [PATCH 22/24] mypy --- altair/jupyter/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/altair/jupyter/__init__.py b/altair/jupyter/__init__.py index ad19efada..651ab11e4 100644 --- a/altair/jupyter/__init__.py +++ b/altair/jupyter/__init__.py @@ -5,7 +5,7 @@ # 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: # type: ignore + class JupyterChart: def __init__(self, *args, **kwargs): raise ImportError( "The Altair JupyterChart requires the anywidget \n" @@ -15,5 +15,6 @@ def __init__(self, *args, **kwargs): " conda install -c conda-forge anywidget\n" "Afterwards, you will need to restart your Python kernel." ) + else: - from .jupyter_chart import JupyterChart + from .jupyter_chart import JupyterChart # noqa: F401 From 2e4a48eb4e849515cff4ee305393f36ed6359e58 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 1 Aug 2023 09:44:38 -0400 Subject: [PATCH 23/24] Move non-widget selection logic to `util.selection` --- altair/jupyter/jupyter_chart.py | 82 ++----------------- altair/utils/selection.py | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 75 deletions(-) create mode 100644 altair/utils/selection.py diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index bfd64a4f1..02ecbccb3 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -1,12 +1,12 @@ import anywidget import traitlets import pathlib -from dataclasses import dataclass -from typing import Any, Dict, List +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 @@ -91,62 +91,6 @@ def _set_value(self, key, value): self.observe(self._make_read_only, names=key) -@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 JupyterChart(anywidget.AnyWidget): _esm = _here / "js" / "index.js" _css = r""" @@ -276,31 +220,19 @@ def _on_change_selections(self, change): 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] - self.selections._set_value( selection_name, - IndexSelection(name=selection_name, value=indices, store=store), + IndexSelection.from_vega(selection_name, signal=value, store=store), ) elif selection_type == "point": - if value is None: - points = [] - else: - points = value.get("vlPoint", {}).get("or", []) - self.selections._set_value( selection_name, - PointSelection(name=selection_name, value=points, store=store), + PointSelection.from_vega(selection_name, signal=value, store=store), ) elif selection_type == "interval": - if value is None: - value = {} - self.selections._set_value( selection_name, - IntervalSelection(name=selection_name, value=value, store=store), + IntervalSelection.from_vega( + selection_name, signal=value, store=store + ), ) diff --git a/altair/utils/selection.py b/altair/utils/selection.py new file mode 100644 index 000000000..2e796cac0 --- /dev/null +++ b/altair/utils/selection.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass +from typing import List, Dict, Any, NewType, Optional + + +# Type representing the "{selection}_store" dataset that corresponds to a +# Vega-Lite selection +Store = NewType("Store", List[Dict[str, Any]]) + + +@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: Store + + @staticmethod + def from_vega(name: str, signal: Optional[Dict[str, dict]], store: Store): + """ + Construct an IndexSelection from the raw Vega signal and dataset values. + + Parameters + ---------- + name: str + The selection's name + signal: dict or None + The value of the Vega signal corresponding to the selection + store: list + The value of the Vega dataset corresponding to the selection. + This dataset is named "{name}_store" in the Vega view. + + Returns + ------- + IndexSelection + """ + if signal is None: + indices = [] + else: + points = signal.get("vlPoint", {}).get("or", []) + indices = [p["_vgsid_"] - 1 for p in points] + return IndexSelection(name=name, value=indices, store=store) + + +@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: Store + + @staticmethod + def from_vega(name: str, signal: Optional[Dict[str, dict]], store: Store): + """ + Construct a PointSelection from the raw Vega signal and dataset values. + + Parameters + ---------- + name: str + The selection's name + signal: dict or None + The value of the Vega signal corresponding to the selection + store: list + The value of the Vega dataset corresponding to the selection. + This dataset is named "{name}_store" in the Vega view. + + Returns + ------- + PointSelection + """ + if signal is None: + points = [] + else: + points = signal.get("vlPoint", {}).get("or", []) + return PointSelection(name=name, value=points, store=store) + + +@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: Store + + @staticmethod + def from_vega(name: str, signal: Optional[Dict[str, list]], store: Store): + """ + Construct an IntervalSelection from the raw Vega signal and dataset values. + + Parameters + ---------- + name: str + The selection's name + signal: dict or None + The value of the Vega signal corresponding to the selection + store: list + The value of the Vega dataset corresponding to the selection. + This dataset is named "{name}_store" in the Vega view. + + Returns + ------- + PointSelection + """ + if signal is None: + signal = {} + return IntervalSelection(name=name, value=signal, store=store) From 0fa4723639f28b148c1cf65920bd84ff26a60c58 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 1 Aug 2023 18:36:03 -0400 Subject: [PATCH 24/24] Use lodash's debounce for maxWait functionality --- altair/jupyter/js/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index fd71cd282..436afd7a2 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,5 +1,5 @@ import embed from "https://cdn.jsdelivr.net/npm/vega-embed@6/+esm"; -import debounce from 'https://cdn.jsdelivr.net/npm/just-debounce-it@3.2.0/+esm' +import { debounce } from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js" export async function render({ model, el }) { let finalize; @@ -32,6 +32,7 @@ export async function render({ model, el }) { // Debounce config const wait = model.get("debounce_wait") ?? 10; + const maxWait = wait; const initialSelections = {}; for (const selectionName of Object.keys(model.get("_vl_selections"))) { @@ -43,7 +44,7 @@ export async function render({ model, el }) { model.set("_vl_selections", newSelections); model.save_changes(); }; - api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, true)); + api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait})); initialSelections[selectionName] = {value: {}, store: []} } @@ -57,7 +58,7 @@ export async function render({ model, el }) { model.set("_params", newParams); model.save_changes(); }; - api.view.addSignalListener(paramName, debounce(paramHandler, wait, true)); + api.view.addSignalListener(paramName, debounce(paramHandler, wait, {maxWait})); initialParams[paramName] = api.view.signal(paramName) ?? null }