From 696472789bb7873807d79f41f7ae7e61d92de710 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 9 Dec 2023 06:41:25 -0500 Subject: [PATCH 01/14] Enhance JupyterChart to handle timezone and None charts --- altair/jupyter/js/index.js | 11 +++++++++++ altair/jupyter/jupyter_chart.py | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 81ad634e6..6e9a2d60c 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -19,7 +19,18 @@ export async function render({ model, el }) { finalize(); } + model.set("local_tz", Intl.DateTimeFormat().resolvedOptions().timeZone); + let spec = model.get("spec"); + if (spec == null) { + // Remove any existing chart and return + while (el.firstChild) { + el.removeChild(el.lastChild); + } + model.save_changes(); + return; + } + let api; try { api = await embed(el, spec); diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 5b2f4af68..f777dd8f1 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -101,9 +101,10 @@ class JupyterChart(anywidget.AnyWidget): """ # Public traitlets - chart = traitlets.Instance(TopLevelSpec) - spec = traitlets.Dict().tag(sync=True) + chart = traitlets.Instance(TopLevelSpec, allow_none=True) + spec = traitlets.Dict(allow_none=True).tag(sync=True) debounce_wait = traitlets.Float(default_value=10).tag(sync=True) + local_tz = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True) # Internal selection traitlets _selection_types = traitlets.Dict() @@ -135,14 +136,22 @@ def _on_change_chart(self, change): state when the wrapped Chart instance changes """ new_chart = change.new - - params = getattr(new_chart, "params", []) selection_watches = [] selection_types = {} initial_params = {} initial_vl_selections = {} empty_selections = {} + if new_chart is None: + with self.hold_sync(): + self.spec = None + self._selection_types = selection_types + self._vl_selections = initial_vl_selections + self._params = initial_params + return + + params = getattr(new_chart, "params", []) + if params is not alt.Undefined: for param in new_chart.params: if isinstance(param.name, alt.ParameterName): From 6230680a20f1d4e424fb611f93c1604fbd2a3f70 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 9 Dec 2023 13:10:26 -0500 Subject: [PATCH 02/14] Update JupyterChart to support VegaFusion ChartState --- altair/jupyter/js/index.js | 121 ++++++++++++++++++++++++++++++- altair/jupyter/jupyter_chart.py | 39 +++++++++- altair/utils/_vegafusion_data.py | 64 ++++++++++++++-- 3 files changed, 216 insertions(+), 8 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 6e9a2d60c..cf7a18c47 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,5 +1,6 @@ import embed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; import debounce from "https://esm.sh/lodash-es@4.17.21/debounce"; +import cloneDeep from "https://esm.sh/lodash-es@4.17.21/cloneDeep"; export async function render({ model, el }) { let finalize; @@ -21,7 +22,7 @@ export async function render({ model, el }) { model.set("local_tz", Intl.DateTimeFormat().resolvedOptions().timeZone); - let spec = model.get("spec"); + let spec = cloneDeep(model.get("spec")); if (spec == null) { // Remove any existing chart and return while (el.firstChild) { @@ -87,6 +88,47 @@ export async function render({ model, el }) { } await api.view.runAsync(); }); + + // Add signal/data listeners + for (const watch of model.get("_js_watch_plan") ?? []) { + if (watch.namespace === "data") { + const dataHandler = (_, value) => { + model.set("_js_to_py_updates", [{ + namespace: "data", + name: watch.name, + scope: watch.scope, + value: cleanJson(value) + }]); + model.save_changes(); + }; + addDataListener(api.view, watch.name, watch.scope, debounce(dataHandler, wait, maxWait)) + + } else if (watch.namespace === "signal") { + const signalHandler = (_, value) => { + model.set("_js_to_py_updates", [{ + namespace: "signal", + name: watch.name, + scope: watch.scope, + value: cleanJson(value) + }]); + model.save_changes(); + }; + + addSignalListener(api.view, watch.name, watch.scope, debounce(signalHandler, wait, maxWait)) + } + } + + // Add signal/data updaters + model.on('change:_py_to_js_updates', async (updates) => { + for (const update of updates.changed._py_to_js_updates ?? []) { + if (update.namespace === "signal") { + setSignalValue(api.view, update.name, update.scope, update.value); + } else if (update.namespace === "data") { + setDataValue(api.view, update.name, update.scope, update.value); + } + } + await api.view.runAsync(); + }); } model.on('change:spec', reembed); @@ -96,4 +138,81 @@ export async function render({ model, el }) { function cleanJson(data) { return JSON.parse(JSON.stringify(data)) +} + +function getNestedRuntime(view, scope) { + var runtime = view._runtime; + for (const index of scope) { + runtime = runtime.subcontext[index]; + } + return runtime +} + +function lookupSignalOp(view, name, scope) { + let parent_runtime = getNestedRuntime(view, scope); + return parent_runtime.signals[name] ?? null; +} + +function dataRef(view, name, scope) { + let parent_runtime = getNestedRuntime(view, scope); + return parent_runtime.data[name]; +} + +export function setSignalValue(view, name, scope, value) { + let signal_op = lookupSignalOp(view, name, scope); + view.update(signal_op, value); +} + +export function setDataValue(view, name, scope, value) { + let dataset = dataRef(view, name, scope); + let changeset = view.changeset().remove(() => true).insert(value) + dataset.modified = true; + view.pulse(dataset.input, changeset); +} + +export function addSignalListener(view, name, scope, handler) { + let signal_op = lookupSignalOp(view, name, scope); + return addOperatorListener( + view, + name, + signal_op, + handler, + ); +} + +export function addDataListener(view, name, scope, handler) { + let dataset = dataRef(view, name, scope).values; + return addOperatorListener( + view, + name, + dataset, + handler, + ); +} + +// Private helpers from Vega for dealing with nested signals/data +function findOperatorHandler(op, handler) { + const h = (op._targets || []) + .filter(op => op._update && op._update.handler === handler); + return h.length ? h[0] : null; +} + +function addOperatorListener(view, name, op, handler) { + let h = findOperatorHandler(op, handler); + if (!h) { + h = trap(view, () => handler(name, op.value)); + h.handler = handler; + view.on(op, null, h); + } + return view; +} + +function trap(view, fn) { + return !fn ? null : function() { + try { + fn.apply(this, arguments); + } catch (error) { + view.error(error); + } + }; } \ No newline at end of file diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index f777dd8f1..d11dced14 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -4,7 +4,10 @@ from typing import Any, Set import altair as alt -from altair.utils._vegafusion_data import using_vegafusion +from altair.utils._vegafusion_data import ( + using_vegafusion, + compile_to_vegafusion_chart_state, +) from altair import TopLevelSpec from altair.utils.selection import IndexSelection, PointSelection, IntervalSelection @@ -113,6 +116,12 @@ class JupyterChart(anywidget.AnyWidget): # Internal param traitlets _params = traitlets.Dict().tag(sync=True) + # Internal comm traitlets for VegaFusion support + _chart_state = traitlets.Any(allow_none=True) + _js_watch_plan = traitlets.List().tag(sync=True) + _js_to_py_updates = traitlets.List().tag(sync=True) + _py_to_js_updates = 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 @@ -214,13 +223,39 @@ def on_param_traitlet_changed(param_change): # Update properties all together with self.hold_sync(): if using_vegafusion(): - self.spec = new_chart.to_dict(format="vega") + if self.local_tz is None: + self.spec = None + + def on_local_tz_change(change): + self._init_with_vegafusion(change["new"]) + + self.observe(on_local_tz_change, ["local_tz"]) + else: + self._init_with_vegafusion(self.local_tz) else: self.spec = new_chart.to_dict() self._selection_types = selection_types self._vl_selections = initial_vl_selections self._params = initial_params + def _init_with_vegafusion(self, local_tz: str): + vegalite_spec = self.chart.to_dict( + format="vega-lite", context={"pre_transform": False} + ) + with self.hold_sync(): + self._chart_state = compile_to_vegafusion_chart_state( + vegalite_spec, local_tz + ) + self._js_watch_plan = self._chart_state.get_watch_plan()["client_to_server"] + self.spec = self._chart_state.get_transformed_spec() + + # Callback to update chart state and send updates back to client + def on_js_to_py_updates(change): + updates = self._chart_state.update(change["new"]) + self._py_to_js_updates = updates + + self.observe(on_js_to_py_updates, ["_js_to_py_updates"]) + @traitlets.observe("_params") def _on_change_params(self, change): for param_name, value in change.new.items(): diff --git a/altair/utils/_vegafusion_data.py b/altair/utils/_vegafusion_data.py index 65585e5bf..8d39fb466 100644 --- a/altair/utils/_vegafusion_data.py +++ b/altair/utils/_vegafusion_data.py @@ -2,9 +2,7 @@ import uuid from weakref import WeakValueDictionary -from typing import Union, Dict, Set, MutableMapping - -from typing import TypedDict, Final +from typing import Union, Dict, Set, MutableMapping, TypedDict, Final, Any from altair.utils._importers import import_vegafusion from altair.utils.core import DataFrameLike @@ -124,6 +122,58 @@ def get_inline_tables(vega_spec: dict) -> Dict[str, DataFrameLike]: return tables +def compile_to_vegafusion_chart_state(vegalite_spec: dict, local_tz: str) -> Any: + """Compile a Vega-Lite spec to a VegaFusion ChartState + + Note: This function should only be called on a Vega-Lite spec + that was generated with the "vegafusion" data transformer enabled. + In particular, this spec may contain references to extract datasets + using table:// prefixed URLs. + + Parameters + ---------- + vegalite_spec: dict + A Vega-Lite spec that was generated from an Altair chart with + the "vegafusion" data transformer enabled + local_tz: str + Local timezone name (e.g. 'America/New_York') + + Returns + ------- + ChartState + A VegaFusion ChartState object + """ + # Local import to avoid circular ImportError + from altair import vegalite_compilers, data_transformers + + vf = import_vegafusion() + + # Compile Vega-Lite spec to Vega + compiler = vegalite_compilers.get() + if compiler is None: + raise ValueError("No active vega-lite compiler plugin found") + + vega_spec = compiler(vegalite_spec) + + # Retrieve dict of inline tables referenced by the spec + inline_tables = get_inline_tables(vega_spec) + + # Pre-evaluate transforms in vega spec with vegafusion + row_limit = data_transformers.options.get("max_rows", None) + + chart_state = vf.runtime.new_chart_state( + vega_spec, + local_tz=local_tz, + inline_datasets=inline_tables, + row_limit=row_limit, + ) + + # Check from row limit warning and convert to MaxRowsError + handle_row_limit_exceeded(row_limit, chart_state.get_warnings()) + + return chart_state + + def compile_with_vegafusion(vegalite_spec: dict) -> dict: """Compile a Vega-Lite spec to Vega and pre-transform with VegaFusion @@ -168,6 +218,12 @@ def compile_with_vegafusion(vegalite_spec: dict) -> dict: ) # Check from row limit warning and convert to MaxRowsError + handle_row_limit_exceeded(row_limit, warnings) + + return transformed_vega_spec + + +def handle_row_limit_exceeded(row_limit, warnings): for warning in warnings: if warning.get("type") == "RowLimitExceeded": raise MaxRowsError( @@ -178,8 +234,6 @@ def compile_with_vegafusion(vegalite_spec: dict) -> dict: "disabling this limit may cause the browser to freeze or crash." ) - return transformed_vega_spec - def using_vegafusion() -> bool: """Check whether the vegafusion data transformer is enabled""" From cda4eb76529e8e1491aaf6d590c999fffb29c918 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 07:22:25 -0500 Subject: [PATCH 03/14] Bump minimum vegafusion version to 1.5.0 for ChartState support --- altair/utils/_importers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/altair/utils/_importers.py b/altair/utils/_importers.py index c8cd21c95..3032b6e26 100644 --- a/altair/utils/_importers.py +++ b/altair/utils/_importers.py @@ -4,7 +4,7 @@ def import_vegafusion() -> ModuleType: - min_version = "1.4.0" + min_version = "1.5.0" try: version = importlib_version("vegafusion") if Version(version) < Version(min_version): From 9147cbb56a04308d4b35b269192f1b1de78b2223 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 07:26:33 -0500 Subject: [PATCH 04/14] Add max_wait option to JupyterChart --- altair/jupyter/js/index.js | 14 +++++++++----- altair/jupyter/jupyter_chart.py | 20 +++++++++++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index cf7a18c47..37b8b739c 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -44,7 +44,10 @@ export async function render({ model, el }) { // Debounce config const wait = model.get("debounce_wait") ?? 10; - const maxWait = wait; + const debounceOpts = {leading: false, trailing: true}; + if (model.get("max_wait") ?? true) { + debounceOpts["maxWait"] = wait; + } const initialSelections = {}; for (const selectionName of Object.keys(model.get("_vl_selections"))) { @@ -57,7 +60,7 @@ export async function render({ model, el }) { model.set("_vl_selections", newSelections); model.save_changes(); }; - api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, {maxWait})); + api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, debounceOpts)); initialSelections[selectionName] = { value: cleanJson(api.view.signal(selectionName) ?? {}), @@ -74,7 +77,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, debounceOpts)); initialParams[paramName] = api.view.signal(paramName) ?? null } @@ -101,7 +104,7 @@ export async function render({ model, el }) { }]); model.save_changes(); }; - addDataListener(api.view, watch.name, watch.scope, debounce(dataHandler, wait, maxWait)) + addDataListener(api.view, watch.name, watch.scope, debounce(dataHandler, wait, debounceOpts)) } else if (watch.namespace === "signal") { const signalHandler = (_, value) => { @@ -114,7 +117,7 @@ export async function render({ model, el }) { model.save_changes(); }; - addSignalListener(api.view, watch.name, watch.scope, debounce(signalHandler, wait, maxWait)) + addSignalListener(api.view, watch.name, watch.scope, debounce(signalHandler, wait, debounceOpts)) } } @@ -133,6 +136,7 @@ export async function render({ model, el }) { model.on('change:spec', reembed); model.on('change:debounce_wait', reembed); + model.on('change:max_wait', reembed); await reembed(); } diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index d11dced14..9ad739d11 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -107,6 +107,7 @@ class JupyterChart(anywidget.AnyWidget): chart = traitlets.Instance(TopLevelSpec, allow_none=True) spec = traitlets.Dict(allow_none=True).tag(sync=True) debounce_wait = traitlets.Float(default_value=10).tag(sync=True) + max_wait = traitlets.Bool(default_value=True).tag(sync=True) local_tz = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True) # Internal selection traitlets @@ -122,7 +123,13 @@ class JupyterChart(anywidget.AnyWidget): _js_to_py_updates = traitlets.List().tag(sync=True) _py_to_js_updates = traitlets.List().tag(sync=True) - def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): + def __init__( + self, + chart: TopLevelSpec, + debounce_wait: int = 10, + max_wait: bool = True, + **kwargs: Any, + ): """ Jupyter Widget for displaying and updating Altair Charts, and retrieving selection and parameter values @@ -132,11 +139,18 @@ def __init__(self, chart: TopLevelSpec, debounce_wait: int = 10, **kwargs: Any): chart: Chart Altair Chart instance debounce_wait: int - Debouncing wait time in milliseconds + Debouncing wait time in milliseconds. Updates will be sent from the client to the kernel + after debounce_wait milliseconds of no chart interactions. + max_wait: bool + If True (default), updates will be sent from the client to the kernel every debounce_wait + milliseconds even if there are ongoing chart interactions. If False, updates will not be + sent until chart interactions have completed. """ self.params = Params({}) self.selections = Selections({}) - super().__init__(chart=chart, debounce_wait=debounce_wait, **kwargs) + super().__init__( + chart=chart, debounce_wait=debounce_wait, max_wait=max_wait, **kwargs + ) @traitlets.observe("chart") def _on_change_chart(self, change): From 00f37014474019ebf2a926fcb09f92d11484ee48 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 07:27:15 -0500 Subject: [PATCH 05/14] Improve type hints --- altair/utils/_vegafusion_data.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/altair/utils/_vegafusion_data.py b/altair/utils/_vegafusion_data.py index 8d39fb466..8b46bab78 100644 --- a/altair/utils/_vegafusion_data.py +++ b/altair/utils/_vegafusion_data.py @@ -2,13 +2,24 @@ import uuid from weakref import WeakValueDictionary -from typing import Union, Dict, Set, MutableMapping, TypedDict, Final, Any +from typing import ( + Union, + Dict, + Set, + MutableMapping, + TypedDict, + Final, + TYPE_CHECKING, +) from altair.utils._importers import import_vegafusion from altair.utils.core import DataFrameLike from altair.utils.data import DataType, ToValuesReturnType, MaxRowsError from altair.vegalite.data import default_data_transformer +if TYPE_CHECKING: + from vegafusion.runtime import ChartState # type: ignore + # Temporary storage for dataframes that have been extracted # from charts by the vegafusion data transformer. Use a WeakValueDictionary # rather than a dict so that the Python interpreter is free to garbage @@ -122,7 +133,9 @@ def get_inline_tables(vega_spec: dict) -> Dict[str, DataFrameLike]: return tables -def compile_to_vegafusion_chart_state(vegalite_spec: dict, local_tz: str) -> Any: +def compile_to_vegafusion_chart_state( + vegalite_spec: dict, local_tz: str +) -> "ChartState": """Compile a Vega-Lite spec to a VegaFusion ChartState Note: This function should only be called on a Vega-Lite spec @@ -223,7 +236,7 @@ def compile_with_vegafusion(vegalite_spec: dict) -> dict: return transformed_vega_spec -def handle_row_limit_exceeded(row_limit, warnings): +def handle_row_limit_exceeded(row_limit: int, warnings: list): for warning in warnings: if warning.get("type") == "RowLimitExceeded": raise MaxRowsError( From 583b6aa82043b055ed396aaea2d5b0181e9140e9 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 07:28:49 -0500 Subject: [PATCH 06/14] Help mypy --- altair/utils/mimebundle.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/altair/utils/mimebundle.py b/altair/utils/mimebundle.py index d48470041..a06f1f199 100644 --- a/altair/utils/mimebundle.py +++ b/altair/utils/mimebundle.py @@ -72,9 +72,11 @@ def spec_to_mimebundle( # Default to the embed options set by alt.renderers.set_embed_options if embed_options is None: - embed_options = renderers.options.get("embed_options", {}) + final_embed_options = renderers.options.get("embed_options", {}) + else: + final_embed_options = embed_options - embed_options = preprocess_embed_options(embed_options) + embed_options = preprocess_embed_options(final_embed_options) if format in ["png", "svg", "pdf", "vega"]: format = cast(Literal["png", "svg", "pdf", "vega"], format) From ae373a9edf6f5266d3a14f7437e9cde563c3298b Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 07:32:13 -0500 Subject: [PATCH 07/14] bump vegafusion in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0b7c46de2..ee90975b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dev = [ "types-jsonschema", "types-setuptools", "pyarrow>=11", - "vegafusion[embed]>=1.4.0", + "vegafusion[embed]>=1.5.0", "anywidget", "geopandas", ] From 11a905692f95cb81b8645b9d62acc37480965071 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Tue, 12 Dec 2023 17:18:07 -0500 Subject: [PATCH 08/14] Fix JupyterChart tests --- tests/test_jupyter_chart.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_jupyter_chart.py b/tests/test_jupyter_chart.py index 3eebcc9dc..d6d7815a3 100644 --- a/tests/test_jupyter_chart.py +++ b/tests/test_jupyter_chart.py @@ -30,6 +30,9 @@ def test_chart_with_no_interactivity(transformer): widget = alt.JupyterChart(chart) if transformer == "vegafusion": + # With the "vegafusion" transformer, the spec is not computed until the front-end + # sets the local_tz. Assign this property manually to simulate this. + widget.local_tz = "UTC" assert widget.spec == chart.to_dict(format="vega") else: assert widget.spec == chart.to_dict() @@ -59,6 +62,7 @@ def test_interval_selection_example(transformer): widget = alt.JupyterChart(chart) if transformer == "vegafusion": + widget.local_tz = "UTC" assert widget.spec == chart.to_dict(format="vega") else: assert widget.spec == chart.to_dict() @@ -126,6 +130,7 @@ def test_index_selection_example(transformer): widget = alt.JupyterChart(chart) if transformer == "vegafusion": + widget.local_tz = "UTC" assert widget.spec == chart.to_dict(format="vega") else: assert widget.spec == chart.to_dict() @@ -185,6 +190,7 @@ def test_point_selection(transformer): widget = alt.JupyterChart(chart) if transformer == "vegafusion": + widget.local_tz = "UTC" assert widget.spec == chart.to_dict(format="vega") else: assert widget.spec == chart.to_dict() From 8689751a1e2bf378fd6182d45c665c7d3120e09d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 13 Dec 2023 14:20:43 -0500 Subject: [PATCH 09/14] Use float if initial param value is int --- altair/jupyter/jupyter_chart.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index 9ad739d11..ee9abf025 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -23,9 +23,7 @@ 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): + if isinstance(value, (int, float)): traitlet_type = traitlets.Float() elif isinstance(value, str): traitlet_type = traitlets.Unicode() From 893bc5973b436a524122345f548366d7aea93636 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 13 Dec 2023 18:01:24 -0500 Subject: [PATCH 10/14] Add debug property and use this to enable printing VegaFusion messages (these will end up in the JupyterLab console) --- altair/jupyter/jupyter_chart.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index ee9abf025..c7478694c 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -1,3 +1,4 @@ +import json import anywidget import traitlets import pathlib @@ -107,6 +108,7 @@ class JupyterChart(anywidget.AnyWidget): debounce_wait = traitlets.Float(default_value=10).tag(sync=True) max_wait = traitlets.Bool(default_value=True).tag(sync=True) local_tz = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True) + debug = traitlets.Bool(default_value=False) # Internal selection traitlets _selection_types = traitlets.Dict() @@ -126,6 +128,7 @@ def __init__( chart: TopLevelSpec, debounce_wait: int = 10, max_wait: bool = True, + debug: bool = False, **kwargs: Any, ): """ @@ -143,11 +146,17 @@ def __init__( If True (default), updates will be sent from the client to the kernel every debounce_wait milliseconds even if there are ongoing chart interactions. If False, updates will not be sent until chart interactions have completed. + debug: bool + If True, debug messages will be printed """ self.params = Params({}) self.selections = Selections({}) super().__init__( - chart=chart, debounce_wait=debounce_wait, max_wait=max_wait, **kwargs + chart=chart, + debounce_wait=debounce_wait, + max_wait=max_wait, + debug=debug, + **kwargs, ) @traitlets.observe("chart") @@ -263,7 +272,13 @@ def _init_with_vegafusion(self, local_tz: str): # Callback to update chart state and send updates back to client def on_js_to_py_updates(change): + if self.debug: + updates_str = json.dumps(change["new"], indent=2) + print(f"JavaScript to Python VegaFusion updates:\n {updates_str}") updates = self._chart_state.update(change["new"]) + if self.debug: + updates_str = json.dumps(updates, indent=2) + print(f"Python to JavaScript VegaFusion updates:\n {updates_str}") self._py_to_js_updates = updates self.observe(on_js_to_py_updates, ["_js_to_py_updates"]) From ee2aed4533b2e2e4447f65fd707892e04cb56259 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Wed, 13 Dec 2023 18:27:51 -0500 Subject: [PATCH 11/14] mypy fixes --- altair/jupyter/jupyter_chart.py | 55 ++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/altair/jupyter/jupyter_chart.py b/altair/jupyter/jupyter_chart.py index c7478694c..fc973a5a8 100644 --- a/altair/jupyter/jupyter_chart.py +++ b/altair/jupyter/jupyter_chart.py @@ -119,9 +119,9 @@ class JupyterChart(anywidget.AnyWidget): # Internal comm traitlets for VegaFusion support _chart_state = traitlets.Any(allow_none=True) - _js_watch_plan = traitlets.List().tag(sync=True) - _js_to_py_updates = traitlets.List().tag(sync=True) - _py_to_js_updates = traitlets.List().tag(sync=True) + _js_watch_plan = traitlets.Any(allow_none=True).tag(sync=True) + _js_to_py_updates = traitlets.Any(allow_none=True).tag(sync=True) + _py_to_js_updates = traitlets.Any(allow_none=True).tag(sync=True) def __init__( self, @@ -260,28 +260,33 @@ def on_local_tz_change(change): self._params = initial_params def _init_with_vegafusion(self, local_tz: str): - vegalite_spec = self.chart.to_dict( - format="vega-lite", context={"pre_transform": False} - ) - with self.hold_sync(): - self._chart_state = compile_to_vegafusion_chart_state( - vegalite_spec, local_tz - ) - self._js_watch_plan = self._chart_state.get_watch_plan()["client_to_server"] - self.spec = self._chart_state.get_transformed_spec() - - # Callback to update chart state and send updates back to client - def on_js_to_py_updates(change): - if self.debug: - updates_str = json.dumps(change["new"], indent=2) - print(f"JavaScript to Python VegaFusion updates:\n {updates_str}") - updates = self._chart_state.update(change["new"]) - if self.debug: - updates_str = json.dumps(updates, indent=2) - print(f"Python to JavaScript VegaFusion updates:\n {updates_str}") - self._py_to_js_updates = updates - - self.observe(on_js_to_py_updates, ["_js_to_py_updates"]) + if self.chart is not None: + vegalite_spec = self.chart.to_dict(context={"pre_transform": False}) + with self.hold_sync(): + self._chart_state = compile_to_vegafusion_chart_state( + vegalite_spec, local_tz + ) + self._js_watch_plan = self._chart_state.get_watch_plan()[ + "client_to_server" + ] + self.spec = self._chart_state.get_transformed_spec() + + # Callback to update chart state and send updates back to client + def on_js_to_py_updates(change): + if self.debug: + updates_str = json.dumps(change["new"], indent=2) + print( + f"JavaScript to Python VegaFusion updates:\n {updates_str}" + ) + updates = self._chart_state.update(change["new"]) + if self.debug: + updates_str = json.dumps(updates, indent=2) + print( + f"Python to JavaScript VegaFusion updates:\n {updates_str}" + ) + self._py_to_js_updates = updates + + self.observe(on_js_to_py_updates, ["_js_to_py_updates"]) @traitlets.observe("_params") def _on_change_params(self, change): From 41baec08e76c02aee1cf4039f0559a17d4386c09 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Dec 2023 10:56:16 -0500 Subject: [PATCH 12/14] Update Large Dataset documentation with JupyterChart usage --- doc/user_guide/large_datasets.rst | 57 ++++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/doc/user_guide/large_datasets.rst b/doc/user_guide/large_datasets.rst index 18376da41..39c5f8756 100644 --- a/doc/user_guide/large_datasets.rst +++ b/doc/user_guide/large_datasets.rst @@ -90,7 +90,9 @@ unused columns, which reduces dataset size even for charts without data transfor When the ``"vegafusion"`` data transformer is active, data transformations will be pre-evaluated when :ref:`displaying-charts`, :ref:`user-guide-saving`, converted charts a dictionaries, -and converting charts to JSON. +and converting charts to JSON. When combined with :ref:`user-guide-jupyterchart` or the ``"jupyter"`` +renderer (See :ref:`customizing-renderers`), data transformations will also be evaluated in Python +dynamically in response to chart selection events. VegaFusion's development is sponsored by `Hex `_. @@ -108,8 +110,6 @@ or conda conda install -c conda-forge vegafusion vegafusion-python-embed vl-convert-python -Note that conda packages are not yet available for the Apple Silicon architecture. - Enabling the VegaFusion Data Transformer ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Activate the VegaFusion data transformer with: @@ -123,10 +123,11 @@ All charts created after activating the VegaFusion data transformer will work with datasets containing up to 100,000 rows. VegaFusion's row limit is applied after all supported data transformations have been applied. So you are unlikely to reach it with a chart such as a histogram, -but you may hit it in the case of a large scatter chart or a chart that uses interactivity. -If you need to work with larger datasets, -you can disable the maximum row limit -or switch to using the VegaFusion widget renderer described below. +but you may hit it in the case of a large scatter chart or a chart that includes interactivity +when not using ``JupyterChart`` or the ``"jupyter"`` renderer. + +If you need to work with larger datasets, you can disable the maximum row limit +or switch to using ``JupyterChart`` or the ``"jupyter"`` renderer described below. Converting to JSON or dictionary ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -148,8 +149,8 @@ Local Timezone Configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Some Altair transformations (e.g. :ref:`user-guide-timeunit-transform`) are based on a local timezone. Normally, the browser's local timezone is used. However, because -VegaFusion evaluates these transforms in Python before rendering, it's not possible to -access the browser's timezone. Instead, the local timezone of the Python kernel will be +VegaFusion evaluates these transforms in Python before rendering, it's not always possible +to access the browser's timezone. Instead, the local timezone of the Python kernel will be used by default. In the case of a cloud notebook service, this may be difference than the browser's local timezone. @@ -161,6 +162,9 @@ function. For example: import vegafusion as vf vf.set_local_tz("America/New_York") +When using ``JupyterChart`` or the ``"jupyter"`` renderer, the browser's local timezone +is used. + DuckDB Integration ^^^^^^^^^^^^^^^^^^ VegaFusion provides optional integration with `DuckDB`_. Because DuckDB can perform queries on pandas @@ -169,25 +173,32 @@ which requires this conversion. See the `VegaFusion DuckDB`_ documentation for m Interactivity ^^^^^^^^^^^^^ -For charts that use selections to filter data interactively, the VegaFusion data transformer -will include all of the data that participates in the interaction in the resulting chart -specification. This makes it an unsuitable approach for building interactive charts that filter -large datasets (e.g. crossfiltering a dataset with over a million rows). +When using the default ``"html"`` renderer with charts that use selections to filter data interactively, +the VegaFusion data transformer will include all of the data that participates in the interaction in the resulting chart specification. This makes it an unsuitable approach for building interactive charts that filter large datasets (e.g. crossfiltering a dataset with over a million rows). -The `VegaFusion widget renderer`_ is designed to support this use case, and is available in the -third-party ``vegafusion-jupyter`` package. +The ``JupyterChart`` widget and the ``"jupyter"`` renderer are designed to work with the VegaFusion +data transformer to evaluate data transformations interactively in response to selection events. +This avoids the need to transfer the full dataset to the browser, and so supports +interactive exploration of aggregated datasets on the order of millions of rows. -It is enabled with: +Either use ``JupyterChart`` directly: .. code-block:: python - import vegafusion as vf - vf.enable_widget() + import altair as alt + alt.data_transformers.enable("vegafusion") + ... + alt.JupyterChart(chart) -The widget renderer uses a Jupyter Widget extension to maintain a live connection between the displayed chart -and the Python kernel. This makes it possible for transforms to be evaluated interactively in response to -changes in selections, and to send the datasets to the client in arrow format separately instead of inlining -them in the chart json specification. +Or, enable the ``"jupyter"`` renderer and display charts as usual: + +.. code-block:: python + + import altair as alt + alt.data_transformers.enable("vegafusion") + alt.renderers.enable("jupyter") + ... + chart Charts rendered this way require a running Python kernel and Jupyter Widget extension to display, which works in many frontends including locally in the classic notebook, JupyterLab, and VSCode, @@ -455,8 +466,6 @@ summary statistics to Altair instead of the full dataset. rules + bars + ticks + outliers .. _VegaFusion: https://vegafusion.io -.. _VegaFusion mime renderer: https://vegafusion.io/mime_renderer.html -.. _VegaFusion widget renderer: https://vegafusion.io/widget_renderer.html .. _DuckDB: https://duckdb.org/ .. _VegaFusion DuckDB: https://vegafusion.io/duckdb.html .. _vl-convert: https://github.com/vega/vl-convert From 5a4bdd1bd5ab2ebd35907f89697da003e8121006 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Dec 2023 13:49:53 -0500 Subject: [PATCH 13/14] Use built-in structuredClone to avoid deepClone dependency --- altair/jupyter/js/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index 37b8b739c..d54f66266 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,6 +1,5 @@ import embed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; import debounce from "https://esm.sh/lodash-es@4.17.21/debounce"; -import cloneDeep from "https://esm.sh/lodash-es@4.17.21/cloneDeep"; export async function render({ model, el }) { let finalize; @@ -22,7 +21,7 @@ export async function render({ model, el }) { model.set("local_tz", Intl.DateTimeFormat().resolvedOptions().timeZone); - let spec = cloneDeep(model.get("spec")); + let spec = structuredClone(model.get("spec")); if (spec == null) { // Remove any existing chart and return while (el.firstChild) { From 059c6a539a1759649028f0948f8420d7dd1e7a2d Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Sat, 16 Dec 2023 13:51:36 -0500 Subject: [PATCH 14/14] Rename imports to match vl-convert's bundling convention (for future offline support) --- altair/jupyter/js/index.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/altair/jupyter/js/index.js b/altair/jupyter/js/index.js index d54f66266..9202e99be 100644 --- a/altair/jupyter/js/index.js +++ b/altair/jupyter/js/index.js @@ -1,5 +1,5 @@ -import embed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; -import debounce from "https://esm.sh/lodash-es@4.17.21/debounce"; +import vegaEmbed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.16.3"; +import lodashDebounce from "https://esm.sh/lodash-es@4.17.21/debounce"; export async function render({ model, el }) { let finalize; @@ -33,7 +33,7 @@ export async function render({ model, el }) { let api; try { - api = await embed(el, spec); + api = await vegaEmbed(el, spec); } catch (error) { showError(error) return; @@ -59,7 +59,7 @@ export async function render({ model, el }) { model.set("_vl_selections", newSelections); model.save_changes(); }; - api.view.addSignalListener(selectionName, debounce(selectionHandler, wait, debounceOpts)); + api.view.addSignalListener(selectionName, lodashDebounce(selectionHandler, wait, debounceOpts)); initialSelections[selectionName] = { value: cleanJson(api.view.signal(selectionName) ?? {}), @@ -76,7 +76,7 @@ export async function render({ model, el }) { model.set("_params", newParams); model.save_changes(); }; - api.view.addSignalListener(paramName, debounce(paramHandler, wait, debounceOpts)); + api.view.addSignalListener(paramName, lodashDebounce(paramHandler, wait, debounceOpts)); initialParams[paramName] = api.view.signal(paramName) ?? null } @@ -103,7 +103,7 @@ export async function render({ model, el }) { }]); model.save_changes(); }; - addDataListener(api.view, watch.name, watch.scope, debounce(dataHandler, wait, debounceOpts)) + addDataListener(api.view, watch.name, watch.scope, lodashDebounce(dataHandler, wait, debounceOpts)) } else if (watch.namespace === "signal") { const signalHandler = (_, value) => { @@ -116,7 +116,7 @@ export async function render({ model, el }) { model.save_changes(); }; - addSignalListener(api.view, watch.name, watch.scope, debounce(signalHandler, wait, debounceOpts)) + addSignalListener(api.view, watch.name, watch.scope, lodashDebounce(signalHandler, wait, debounceOpts)) } }