From 89108f0eff973de50e24677ed69ce65d633a1ea0 Mon Sep 17 00:00:00 2001 From: Arvind Satyanarayan Date: Tue, 19 Nov 2019 14:58:30 +0900 Subject: [PATCH] feat: interactive legends (#5305) * Interactive Legend * Correct tests * Enable interactive legends for multi-field selection * Support single type selections * Add tests for interactive legends * Simplify flagging a legend to be interactive if selection present. * Simplify signal assembly for interactive legends. * Simplify conditional encoding for legend items. * Scope legend events to "view" as signals may be nested within subscopes. * Move opacity of unselected legend entries to config. * Use more relaxed predicate to correctly select legend items. This is necessary when a selection is projected over several fields. Using vlSelectionTest will cause all legend items to appear unselected, even if one of the values is within in the selection. Instead, we now test only for the legend item itself, via the top-level resolved signal. * Encapsulate interactive legends in a selection transform. Augmenting existing signal logic with legend events yields buggy behavior when toggling values from the store based on unit name. It is also less efficient in multi-view cases, as each individual view would modify the store based on legend events. Instead, the legend selection transform adds a top-level signal to coordinate all legends for a given selection. Moreover, it allows users to selectively disable interactive legend processing (i.e., "legends": false in a selection definition). * Input widgets should reflect selection status from interactive legends. * Update existing compile-time unit tests. * Snapshot. * Fix build. * Simplify interactive legends by treating it as an alternate binding. * Add interactive legend example. * Update unit tests. * Fix bug with legend symbol opacity. * Add docs for interactive legends. * refactor: simplify logic * chore: update schema * docs: add missing docs for unselectedOpacity * chore: build stuff * chore: rebuild toc * fix: don't output unselectedOpacity in config * refactor: don't interpret numbers as truth values * chore: update examples [CI] * Only name and flag parts as interactive if selections exist on component * React to legend entries (whitespace between symbol, label). * Warn + skip if projecting over an aggregate field (or no field). * chore: update examples [CI] * chore: rebuild runtime examples * fix: resolve issue with symbol and color legend by using deep equal * fix: store selection names (not components) on legend component * refactor: use null coalescing Co-Authored-By: Dominik Moritz --- build/vega-lite-schema.json | 54 ++- examples/compiled/interactive_legend.svg | 1 + examples/compiled/interactive_legend.vg.json | 259 ++++++++++++ .../compiled/interactive_legend_dblclick.svg | 1 + .../interactive_legend_dblclick.vg.json | 259 ++++++++++++ examples/specs/interactive_legend.vl.json | 29 ++ .../specs/interactive_legend_dblclick.vl.json | 30 ++ ...ractive_legend_dblclick_normalized.vl.json | 39 ++ .../interactive_legend_normalized.vl.json | 35 ++ package.json | 2 +- site/_includes/docs_toc.md | 1 + site/docs/encoding/legend.md | 2 +- site/docs/selection/bind.md | 19 +- src/compile/facet.ts | 2 +- src/compile/legend/assemble.ts | 4 +- src/compile/legend/component.ts | 2 + src/compile/legend/encode.ts | 43 +- src/compile/legend/parse.ts | 11 +- src/compile/mark/valueref.ts | 2 +- src/compile/selection/assemble.ts | 4 +- src/compile/selection/index.ts | 11 +- src/compile/selection/interval.ts | 2 +- src/compile/selection/multi.ts | 9 +- src/compile/selection/parse.ts | 9 +- src/compile/selection/transforms/inputs.ts | 10 +- src/compile/selection/transforms/legends.ts | 131 ++++++ src/compile/selection/transforms/nearest.ts | 2 +- src/compile/selection/transforms/project.ts | 25 +- src/compile/selection/transforms/scales.ts | 4 +- src/compile/selection/transforms/toggle.ts | 2 +- .../selection/transforms/transforms.ts | 5 +- src/compile/selection/transforms/translate.ts | 2 +- src/compile/selection/transforms/zoom.ts | 2 +- src/compile/split.ts | 4 +- src/compositemark/errorbar.ts | 2 +- src/guide.ts | 3 +- src/legend.ts | 10 +- src/log/message.ts | 8 +- src/normalize/rangestep.ts | 2 +- src/selection.ts | 32 +- src/transformextract.ts | 2 +- test/compile/legend/encode.test.ts | 4 +- test/compile/selection/inputs.test.ts | 2 + test/compile/selection/legends.test.ts | 394 ++++++++++++++++++ 44 files changed, 1412 insertions(+), 64 deletions(-) create mode 100644 examples/compiled/interactive_legend.svg create mode 100644 examples/compiled/interactive_legend.vg.json create mode 100644 examples/compiled/interactive_legend_dblclick.svg create mode 100644 examples/compiled/interactive_legend_dblclick.vg.json create mode 100644 examples/specs/interactive_legend.vl.json create mode 100644 examples/specs/interactive_legend_dblclick.vl.json create mode 100644 examples/specs/normalized/interactive_legend_dblclick_normalized.vl.json create mode 100644 examples/specs/normalized/interactive_legend_normalized.vl.json create mode 100644 src/compile/selection/transforms/legends.ts create mode 100644 test/compile/selection/legends.test.ts diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 2efbb6766e..abf5c513b7 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -9449,6 +9449,19 @@ }, "type": "object" }, + "LegendBinding": { + "anyOf": [ + { + "enum": [ + "legend" + ], + "type": "string" + }, + { + "$ref": "#/definitions/LegendStreamBinding" + } + ] + }, "LegendConfig": { "additionalProperties": false, "properties": { @@ -9814,6 +9827,10 @@ "titlePadding": { "description": "The padding, in pixels, between title and legend.\n\n__Default value:__ `5`.", "type": "number" + }, + "unselectedOpacity": { + "description": "The opacity of unselected legend entries.\n\n__Default value:__ 0.35.", + "type": "number" } }, "type": "object" @@ -9954,6 +9971,25 @@ }, "type": "object" }, + "LegendStreamBinding": { + "additionalProperties": false, + "properties": { + "legend": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/Stream" + } + ] + } + }, + "required": [ + "legend" + ], + "type": "object" + }, "LineConfig": { "additionalProperties": false, "properties": { @@ -11415,6 +11451,10 @@ "MultiSelection": { "additionalProperties": false, "properties": { + "bind": { + "$ref": "#/definitions/LegendBinding", + "description": "When set, a selection is populated by interacting with the corresponding legend. Direct manipulation interaction is disabled by default;\nto re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property.\n\nLegend bindings are restricted to selections that only specify a single field or encoding." + }, "clear": { "anyOf": [ { @@ -11500,6 +11540,10 @@ "MultiSelectionConfig": { "additionalProperties": false, "properties": { + "bind": { + "$ref": "#/definitions/LegendBinding", + "description": "When set, a selection is populated by interacting with the corresponding legend. Direct manipulation interaction is disabled by default;\nto re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property.\n\nLegend bindings are restricted to selections that only specify a single field or encoding." + }, "clear": { "anyOf": [ { @@ -14067,9 +14111,12 @@ "$ref": "#/definitions/Binding" }, "type": "object" + }, + { + "$ref": "#/definitions/LegendBinding" } ], - "description": "Establish a two-way binding between a single selection and input elements\n(also known as dynamic query widgets). A binding takes the form of\nVega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind)\nor can be a mapping between projected field/encodings and binding definitions.\n\n__See also:__ [`bind`](https://vega.github.io/vega-lite/docs/bind.html) documentation." + "description": "When set, a selection is populated by input elements (also known as dynamic query widgets)\nor by interacting with the corresponding legend. Direct manipulation interaction is disabled by default;\nto re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property.\n\nLegend bindings are restricted to selections that only specify a single field or encoding.\n\nQuery widget binding takes the form of Vega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind)\nor can be a mapping between projected field/encodings and binding definitions.\n\n__See also:__ [`bind`](https://vega.github.io/vega-lite/docs/bind.html) documentation." }, "clear": { "anyOf": [ @@ -14156,9 +14203,12 @@ "$ref": "#/definitions/Binding" }, "type": "object" + }, + { + "$ref": "#/definitions/LegendBinding" } ], - "description": "Establish a two-way binding between a single selection and input elements\n(also known as dynamic query widgets). A binding takes the form of\nVega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind)\nor can be a mapping between projected field/encodings and binding definitions.\n\n__See also:__ [`bind`](https://vega.github.io/vega-lite/docs/bind.html) documentation." + "description": "When set, a selection is populated by input elements (also known as dynamic query widgets)\nor by interacting with the corresponding legend. Direct manipulation interaction is disabled by default;\nto re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property.\n\nLegend bindings are restricted to selections that only specify a single field or encoding.\n\nQuery widget binding takes the form of Vega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind)\nor can be a mapping between projected field/encodings and binding definitions.\n\n__See also:__ [`bind`](https://vega.github.io/vega-lite/docs/bind.html) documentation." }, "clear": { "anyOf": [ diff --git a/examples/compiled/interactive_legend.svg b/examples/compiled/interactive_legend.svg new file mode 100644 index 0000000000..d8c9875257 --- /dev/null +++ b/examples/compiled/interactive_legend.svg @@ -0,0 +1 @@ +20002001200220032004200520062007200820092010date (year-month)AgricultureBusiness servicesConstructionEducation and HealthFinanceGovernmentInformationLeisure and hospitalityManufacturingMining and ExtractionOtherSelf-employedTransportation and UtilitiesWholesale and Retail Tradeseries \ No newline at end of file diff --git a/examples/compiled/interactive_legend.vg.json b/examples/compiled/interactive_legend.vg.json new file mode 100644 index 0000000000..39a175b360 --- /dev/null +++ b/examples/compiled/interactive_legend.vg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 300, + "height": 200, + "style": "cell", + "data": [ + {"name": "industry_store"}, + { + "name": "source_0", + "url": "data/unemployment-across-industries.json", + "format": {"type": "json", "parse": {"date": "date"}}, + "transform": [ + { + "type": "formula", + "as": "yearmonth_date", + "expr": "datetime(year(datum[\"date\"]), month(datum[\"date\"]), 1, 0, 0, 0, 0)" + }, + { + "type": "aggregate", + "groupby": ["yearmonth_date", "series"], + "ops": ["sum"], + "fields": ["count"], + "as": ["sum_count"] + }, + { + "type": "impute", + "field": "sum_count", + "groupby": ["series"], + "key": "yearmonth_date", + "method": "value", + "value": 0 + }, + { + "type": "stack", + "groupby": ["yearmonth_date"], + "field": "sum_count", + "sort": {"field": ["series"], "order": ["descending"]}, + "as": ["sum_count_start", "sum_count_end"], + "offset": "center" + } + ] + } + ], + "signals": [ + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "industry_series_legend", + "value": null, + "on": [ + { + "events": [ + { + "source": "view", + "type": "click", + "markname": "series_legend_symbols" + }, + { + "source": "view", + "type": "click", + "markname": "series_legend_labels" + }, + { + "source": "view", + "type": "click", + "markname": "series_legend_entries" + } + ], + "update": "datum.value || item().items[0].items[0].datum.value", + "force": true + }, + { + "events": [{"source": "view", "type": "click"}], + "update": "!event.item || !datum ? null : industry_series_legend", + "force": true + } + ] + }, + { + "name": "industry", + "update": "vlSelectionResolve(\"industry_store\", \"union\", true)" + }, + { + "name": "industry_tuple", + "update": "industry_series_legend !== null ? {fields: industry_tuple_fields, values: [industry_series_legend]} : null" + }, + { + "name": "industry_tuple_fields", + "value": [{"type": "E", "field": "series"}] + }, + { + "name": "industry_toggle", + "value": false, + "on": [ + { + "events": {"merge": [{"source": "view", "type": "click"}]}, + "update": "event.shiftKey" + } + ] + }, + { + "name": "industry_modify", + "update": "modify(\"industry_store\", industry_toggle ? null : industry_tuple, industry_toggle ? null : true, industry_toggle ? industry_tuple : null)" + } + ], + "marks": [ + { + "name": "pathgroup", + "type": "group", + "from": { + "facet": { + "name": "faceted_path_main", + "data": "source_0", + "groupby": ["series"] + } + }, + "encode": { + "update": { + "width": {"field": {"group": "width"}}, + "height": {"field": {"group": "height"}} + } + }, + "marks": [ + { + "name": "marks", + "type": "area", + "style": ["area"], + "sort": {"field": "datum[\"yearmonth_date\"]"}, + "interactive": true, + "from": {"data": "faceted_path_main"}, + "encode": { + "update": { + "orient": {"value": "vertical"}, + "fill": {"scale": "color", "field": "series"}, + "opacity": [ + { + "test": "!(length(data(\"industry_store\"))) || (vlSelectionTest(\"industry_store\", datum))", + "value": 1 + }, + {"value": 0.2} + ], + "x": {"scale": "x", "field": "yearmonth_date"}, + "y": {"scale": "y", "field": "sum_count_end"}, + "y2": {"scale": "y", "field": "sum_count_start"}, + "defined": { + "signal": "isValid(datum[\"yearmonth_date\"]) && isFinite(+datum[\"yearmonth_date\"]) && isValid(datum[\"sum_count\"]) && isFinite(+datum[\"sum_count\"])" + } + } + } + } + ] + } + ], + "scales": [ + { + "name": "x", + "type": "time", + "domain": {"data": "source_0", "field": "yearmonth_date"}, + "range": [0, {"signal": "width"}] + }, + { + "name": "y", + "type": "linear", + "domain": { + "data": "source_0", + "fields": ["sum_count_start", "sum_count_end"] + }, + "range": [{"signal": "height"}, 0], + "nice": true, + "zero": true + }, + { + "name": "color", + "type": "ordinal", + "domain": {"data": "source_0", "field": "series", "sort": true}, + "range": {"scheme": "category20b"} + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "date (year-month)", + "domain": false, + "tickSize": 0, + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "encode": { + "labels": { + "update": {"text": {"signal": "timeFormat(datum.value, '%Y')"}} + } + }, + "zindex": 0 + } + ], + "legends": [ + { + "fill": "color", + "gradientLength": {"signal": "clamp(height, 64, 200)"}, + "symbolType": "circle", + "title": "series", + "encode": { + "labels": { + "name": "series_legend_labels", + "interactive": true, + "update": { + "opacity": [ + { + "test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))", + "value": 1 + }, + {"value": 0.35} + ] + } + }, + "symbols": { + "name": "series_legend_symbols", + "interactive": true, + "update": { + "opacity": [ + { + "test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))", + "value": 1 + }, + {"value": 0.35} + ] + } + }, + "entries": { + "name": "series_legend_entries", + "interactive": true, + "update": {"fill": {"value": "transparent"}} + } + } + } + ] +} diff --git a/examples/compiled/interactive_legend_dblclick.svg b/examples/compiled/interactive_legend_dblclick.svg new file mode 100644 index 0000000000..d8c9875257 --- /dev/null +++ b/examples/compiled/interactive_legend_dblclick.svg @@ -0,0 +1 @@ +20002001200220032004200520062007200820092010date (year-month)AgricultureBusiness servicesConstructionEducation and HealthFinanceGovernmentInformationLeisure and hospitalityManufacturingMining and ExtractionOtherSelf-employedTransportation and UtilitiesWholesale and Retail Tradeseries \ No newline at end of file diff --git a/examples/compiled/interactive_legend_dblclick.vg.json b/examples/compiled/interactive_legend_dblclick.vg.json new file mode 100644 index 0000000000..92d6f17837 --- /dev/null +++ b/examples/compiled/interactive_legend_dblclick.vg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 300, + "height": 200, + "style": "cell", + "data": [ + {"name": "industry_store"}, + { + "name": "source_0", + "url": "data/unemployment-across-industries.json", + "format": {"type": "json", "parse": {"date": "date"}}, + "transform": [ + { + "type": "formula", + "as": "yearmonth_date", + "expr": "datetime(year(datum[\"date\"]), month(datum[\"date\"]), 1, 0, 0, 0, 0)" + }, + { + "type": "aggregate", + "groupby": ["yearmonth_date", "series"], + "ops": ["sum"], + "fields": ["count"], + "as": ["sum_count"] + }, + { + "type": "impute", + "field": "sum_count", + "groupby": ["series"], + "key": "yearmonth_date", + "method": "value", + "value": 0 + }, + { + "type": "stack", + "groupby": ["yearmonth_date"], + "field": "sum_count", + "sort": {"field": ["series"], "order": ["descending"]}, + "as": ["sum_count_start", "sum_count_end"], + "offset": "center" + } + ] + } + ], + "signals": [ + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "industry_series_legend", + "value": null, + "on": [ + { + "events": [ + { + "source": "view", + "type": "dblclick", + "markname": "series_legend_symbols" + }, + { + "source": "view", + "type": "dblclick", + "markname": "series_legend_labels" + }, + { + "source": "view", + "type": "dblclick", + "markname": "series_legend_entries" + } + ], + "update": "datum.value || item().items[0].items[0].datum.value", + "force": true + }, + { + "events": [{"source": "view", "type": "dblclick"}], + "update": "!event.item || !datum ? null : industry_series_legend", + "force": true + } + ] + }, + { + "name": "industry", + "update": "vlSelectionResolve(\"industry_store\", \"union\", true)" + }, + { + "name": "industry_tuple", + "update": "industry_series_legend !== null ? {fields: industry_tuple_fields, values: [industry_series_legend]} : null" + }, + { + "name": "industry_tuple_fields", + "value": [{"type": "E", "field": "series"}] + }, + { + "name": "industry_toggle", + "value": false, + "on": [ + { + "events": {"merge": [{"source": "view", "type": "dblclick"}]}, + "update": "event.shiftKey" + } + ] + }, + { + "name": "industry_modify", + "update": "modify(\"industry_store\", industry_toggle ? null : industry_tuple, industry_toggle ? null : true, industry_toggle ? industry_tuple : null)" + } + ], + "marks": [ + { + "name": "pathgroup", + "type": "group", + "from": { + "facet": { + "name": "faceted_path_main", + "data": "source_0", + "groupby": ["series"] + } + }, + "encode": { + "update": { + "width": {"field": {"group": "width"}}, + "height": {"field": {"group": "height"}} + } + }, + "marks": [ + { + "name": "marks", + "type": "area", + "style": ["area"], + "sort": {"field": "datum[\"yearmonth_date\"]"}, + "interactive": true, + "from": {"data": "faceted_path_main"}, + "encode": { + "update": { + "orient": {"value": "vertical"}, + "fill": {"scale": "color", "field": "series"}, + "opacity": [ + { + "test": "!(length(data(\"industry_store\"))) || (vlSelectionTest(\"industry_store\", datum))", + "value": 1 + }, + {"value": 0.2} + ], + "x": {"scale": "x", "field": "yearmonth_date"}, + "y": {"scale": "y", "field": "sum_count_end"}, + "y2": {"scale": "y", "field": "sum_count_start"}, + "defined": { + "signal": "isValid(datum[\"yearmonth_date\"]) && isFinite(+datum[\"yearmonth_date\"]) && isValid(datum[\"sum_count\"]) && isFinite(+datum[\"sum_count\"])" + } + } + } + } + ] + } + ], + "scales": [ + { + "name": "x", + "type": "time", + "domain": {"data": "source_0", "field": "yearmonth_date"}, + "range": [0, {"signal": "width"}] + }, + { + "name": "y", + "type": "linear", + "domain": { + "data": "source_0", + "fields": ["sum_count_start", "sum_count_end"] + }, + "range": [{"signal": "height"}, 0], + "nice": true, + "zero": true + }, + { + "name": "color", + "type": "ordinal", + "domain": {"data": "source_0", "field": "series", "sort": true}, + "range": {"scheme": "category20b"} + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "date (year-month)", + "domain": false, + "tickSize": 0, + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "encode": { + "labels": { + "update": {"text": {"signal": "timeFormat(datum.value, '%Y')"}} + } + }, + "zindex": 0 + } + ], + "legends": [ + { + "fill": "color", + "gradientLength": {"signal": "clamp(height, 64, 200)"}, + "symbolType": "circle", + "title": "series", + "encode": { + "labels": { + "name": "series_legend_labels", + "interactive": true, + "update": { + "opacity": [ + { + "test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))", + "value": 1 + }, + {"value": 0.35} + ] + } + }, + "symbols": { + "name": "series_legend_symbols", + "interactive": true, + "update": { + "opacity": [ + { + "test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))", + "value": 1 + }, + {"value": 0.35} + ] + } + }, + "entries": { + "name": "series_legend_entries", + "interactive": true, + "update": {"fill": {"value": "transparent"}} + } + } + } + ] +} diff --git a/examples/specs/interactive_legend.vl.json b/examples/specs/interactive_legend.vl.json new file mode 100644 index 0000000000..6aeaca7d5f --- /dev/null +++ b/examples/specs/interactive_legend.vl.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "width": 300, "height": 200, + "data": {"url": "data/unemployment-across-industries.json"}, + "mark": "area", + "selection": { + "industry": { + "type": "multi", "fields": ["series"], "bind": "legend" + } + }, + "encoding": { + "x": { + "timeUnit": "yearmonth", "field": "date", "type": "temporal", + "axis": {"domain": false, "format": "%Y", "tickSize": 0} + }, + "y": { + "aggregate": "sum", "field": "count", "type": "quantitative", + "stack": "center", "axis": null + }, + "color": { + "field":"series", "type": "nominal", + "scale": {"scheme": "category20b"} + }, + "opacity": { + "condition": {"selection": "industry", "value": 1}, + "value": 0.2 + } + } +} diff --git a/examples/specs/interactive_legend_dblclick.vl.json b/examples/specs/interactive_legend_dblclick.vl.json new file mode 100644 index 0000000000..441de4e757 --- /dev/null +++ b/examples/specs/interactive_legend_dblclick.vl.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "width": 300, "height": 200, + "data": {"url": "data/unemployment-across-industries.json"}, + "mark": "area", + "selection": { + "industry": { + "type": "multi", "fields": ["series"], + "bind": {"legend": "dblclick"} + } + }, + "encoding": { + "x": { + "timeUnit": "yearmonth", "field": "date", "type": "temporal", + "axis": {"domain": false, "format": "%Y", "tickSize": 0} + }, + "y": { + "aggregate": "sum", "field": "count", "type": "quantitative", + "stack": "center", "axis": null + }, + "color": { + "field":"series", "type": "nominal", + "scale": {"scheme": "category20b"} + }, + "opacity": { + "condition": {"selection": "industry", "value": 1}, + "value": 0.2 + } + } +} diff --git a/examples/specs/normalized/interactive_legend_dblclick_normalized.vl.json b/examples/specs/normalized/interactive_legend_dblclick_normalized.vl.json new file mode 100644 index 0000000000..c5498a9d64 --- /dev/null +++ b/examples/specs/normalized/interactive_legend_dblclick_normalized.vl.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "width": 300, + "height": 200, + "data": {"url": "data/unemployment-across-industries.json"}, + "mark": "area", + "selection": { + "industry": { + "type": "multi", + "fields": ["series"], + "bind": {"legend": "dblclick"} + } + }, + "encoding": { + "x": { + "timeUnit": "yearmonth", + "field": "date", + "type": "temporal", + "axis": {"domain": false, "format": "%Y", "tickSize": 0} + }, + "y": { + "aggregate": "sum", + "field": "count", + "type": "quantitative", + "stack": "center", + "axis": null + }, + "color": { + "field": "series", + "type": "nominal", + "scale": {"scheme": "category20b"} + }, + "opacity": { + "condition": {"selection": "industry", "value": 1}, + "value": 0.2 + } + }, + "autosize": {"type": "pad"} +} \ No newline at end of file diff --git a/examples/specs/normalized/interactive_legend_normalized.vl.json b/examples/specs/normalized/interactive_legend_normalized.vl.json new file mode 100644 index 0000000000..eac928afe3 --- /dev/null +++ b/examples/specs/normalized/interactive_legend_normalized.vl.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "width": 300, + "height": 200, + "data": {"url": "data/unemployment-across-industries.json"}, + "mark": "area", + "selection": { + "industry": {"type": "multi", "fields": ["series"], "bind": "legend"} + }, + "encoding": { + "x": { + "timeUnit": "yearmonth", + "field": "date", + "type": "temporal", + "axis": {"domain": false, "format": "%Y", "tickSize": 0} + }, + "y": { + "aggregate": "sum", + "field": "count", + "type": "quantitative", + "stack": "center", + "axis": null + }, + "color": { + "field": "series", + "type": "nominal", + "scale": {"scheme": "category20b"} + }, + "opacity": { + "condition": {"selection": "industry", "value": 1}, + "value": 0.2 + } + }, + "autosize": {"type": "pad"} +} \ No newline at end of file diff --git a/package.json b/package.json index fd02d9f3ec..ea7b84194a 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "test": "jest test/ && yarn lint && yarn schema && jest examples/ && yarn test:runtime", "test:inspect": "node --inspect-brk ./node_modules/.bin/jest --runInBand test", "test:runtime": "TZ=America/Los_Angeles jest test-runtime/", - "test:runtime:generate": "rm -Rf test-runtime/resources && VL_GENERATE_TESTS=true yarn test:runtime", + "test:runtime:generate": "yarn build:only && rm -Rf test-runtime/resources && VL_GENERATE_TESTS=true yarn test:runtime", "watch:build": "yarn build:only && concurrently --kill-others -n Typescript,Rollup 'yarn tsc:src -w' 'rollup -c -w'", "watch:site": "concurrently --kill-others -n Typescript,Rollup 'yarn tsc:site -w' 'rollup -c site/rollup.config.js -w'", "watch:test": "jest --watch" diff --git a/site/_includes/docs_toc.md b/site/_includes/docs_toc.md index ed23b89b3e..5ddebbf169 100644 --- a/site/_includes/docs_toc.md +++ b/site/_includes/docs_toc.md @@ -319,6 +319,7 @@ - [Bind]({{site.baseurl}}/docs/bind.html) - [Input Element Binding]({{site.baseurl}}/docs/bind.html#input-element-binding) - [Scale Binding]({{site.baseurl}}/docs/bind.html#scale-binding) + - [Legend Binding]({{site.baseurl}}/docs/bind.html#legend-binding) - [Clear]({{site.baseurl}}/docs/clear.html) - [Examples]({{site.baseurl}}/docs/clear.html#examples) - [Encodings / Fields]({{site.baseurl}}/docs/project.html) diff --git a/site/docs/encoding/legend.md b/site/docs/encoding/legend.md index 5aa7d98df9..36995ef4de 100644 --- a/site/docs/encoding/legend.md +++ b/site/docs/encoding/legend.md @@ -62,7 +62,7 @@ _See also:_ This [interactive article](https://beta.observablehq.com/@jheer/a-gu ### General -{% include table.html props="cornerRadius,direction,fillColor,legendX,legendY,offset,orient,padding,strokeColor,strokeWidth,type,tickCount,values,zindex" source="Legend" %} +{% include table.html props="cornerRadius,direction,fillColor,legendX,legendY,offset,orient,padding,strokeColor,strokeWidth,type,tickCount,unselectedOpacity,values,zindex" source="Legend" %} ### Gradient diff --git a/site/docs/selection/bind.md b/site/docs/selection/bind.md index 58f5947ace..f7b8241d9a 100644 --- a/site/docs/selection/bind.md +++ b/site/docs/selection/bind.md @@ -5,10 +5,11 @@ title: Bind a Selection permalink: /docs/bind.html --- -Using the `bind` property, selections can be bound in two ways: +Using the `bind` property, selections can be bound in the following ways: - Single selections can be bound to [input elements](#input-element-binding) also known as dynamic query widgets. - Interval selections can be bound to their own [view's scales](#scale-binding) to enable panning & zooming. +- Single and multi selections can be bound to [legends](#legend-binding). ## Input Element Binding @@ -22,7 +23,7 @@ If multiple projections are specified, customized bindings can be specified by m
-**Note:** When a single selection is bound to input widgets, direct manipulation interaction (e.g., clicking or double clicking the visualization) is disabled by default. It can be re-enabled by explicitly specifying the [`on`](selection.html#selection-props) and [`clear`](clear.html) properties. +**Note:** When a single selection is bound to input widgets, direct manipulation interaction (e.g., clicking or double clicking the visualization) is disabled by default. Such interaction can be re-enabled by explicitly specifying the [`on`](selection.html#selection-props) and [`clear`](clear.html) properties. ## Scale Binding @@ -37,3 +38,17 @@ In multi-view displays, binding shared scales will keep the views synchronized. A similar setup can be used to pan and zoom the cells of a scatterplot matrix:
+ +## Legend Binding + +When a single or multi selection is [projected](project.html) over only one field or encoding channel, the `bind` property can be set to `legend` to populate the selection by interacting with the corresponding legend. + +
+ +To customize the events that trigger legend interaction, expand the `bind` property to an object, with a single `legend` property that maps to a [Vega event stream](https://vega.github.io/vega/docs/event-streams/). + +
+ +**Note:** When a selection is bound to legends, direct manipulation interaction (e.g., clicking or double clicking the visualization) is disabled by default. Such interaction can be re-enabled by explicitly specifying the [`on`](selection.html#selection-props) and [`clear`](clear.html) properties. + +**Limitations:** Currently, only binding to [symbol legends](https://vega.github.io/vega-lite/docs/legend.html#legend-types) are supported. diff --git a/src/compile/facet.ts b/src/compile/facet.ts index 65a281f414..761d561b35 100644 --- a/src/compile/facet.ts +++ b/src/compile/facet.ts @@ -324,7 +324,7 @@ export class FacetModel extends ModelWithField { name, data, groupby, - ...(cross || fields.length + ...(cross || fields.length > 0 ? { aggregate: { ...(cross ? {cross} : {}), diff --git a/src/compile/legend/assemble.ts b/src/compile/legend/assemble.ts index bdb07db12f..7d56fbdd78 100644 --- a/src/compile/legend/assemble.ts +++ b/src/compile/legend/assemble.ts @@ -41,7 +41,7 @@ export function assembleLegends(model: Model): VgLegend[] { return vals(legendByDomain) .flat() .map((legendCmpt: LegendComponent) => { - const {labelExpr, ...legend} = legendCmpt.combine(); + const {labelExpr, selections, ...legend} = legendCmpt.combine(); if (legend.encode?.symbols) { const out = legend.encode.symbols.update; @@ -58,7 +58,7 @@ export function assembleLegends(model: Model): VgLegend[] { if (labelExpr !== undefined) { let expr = labelExpr; - if (legend.encode?.labels?.update && isSignalRef(legend.encode.labels.update.text)) { + if (legend.encode?.labels?.update?.text && isSignalRef(legend.encode.labels.update.text)) { expr = replaceAll(labelExpr, 'datum.label', legend.encode.labels.update.text.signal); } diff --git a/src/compile/legend/component.ts b/src/compile/legend/component.ts index c460a08702..670fbac827 100644 --- a/src/compile/legend/component.ts +++ b/src/compile/legend/component.ts @@ -6,11 +6,13 @@ import {Split} from '../split'; export type LegendComponentProps = VgLegend & { labelExpr?: string; + selections?: string[]; }; const LEGEND_COMPONENT_PROPERTY_INDEX: Flag = { ...COMMON_LEGEND_PROPERTY_INDEX, labelExpr: 1, + selections: 1, // channel scales opacity: 1, shape: 1, diff --git a/src/compile/legend/encode.ts b/src/compile/legend/encode.ts index 84d35ca497..135330ae41 100644 --- a/src/compile/legend/encode.ts +++ b/src/compile/legend/encode.ts @@ -1,5 +1,5 @@ import {ColorValueRef, SymbolEncodeEntry} from 'vega'; -import {isArray} from 'vega-util'; +import {isArray, stringValue} from 'vega-util'; import {COLOR, NonPositionScaleChannel, OPACITY} from '../../channel'; import { Conditional, @@ -16,13 +16,14 @@ import { } from '../../channeldef'; import {FILL_STROKE_CONFIG} from '../../mark'; import {ScaleType} from '../../scale'; -import {getFirstDefined, keys} from '../../util'; +import {getFirstDefined, keys, varName} from '../../util'; import {applyMarkConfig, timeFormatExpression} from '../common'; import * as mixins from '../mark/mixins'; import {UnitModel} from '../unit'; import {ScaleChannel} from './../../channel'; import {LegendComponent} from './component'; import {defaultType} from './properties'; +import {STORE} from '../selection'; function type(legendCmp: LegendComponent, model: UnitModel, channel: ScaleChannel) { const scaleType = model.getScaleComponent(channel).get('type'); @@ -49,6 +50,7 @@ export function symbols( const filled = markDef.filled; const opacity = getMaxValue(encoding.opacity) ?? markDef.opacity; + const condition = selectedCondition(model, legendCmp, fieldDef); if (out.fill) { // for fill legend, we don't want any fill in symbol @@ -94,8 +96,9 @@ export function symbols( } if (channel !== OPACITY) { - if (opacity) { - // only apply opacity if it is neither zero or undefined + if (condition) { + out.opacity = [{test: condition, value: opacity ?? 1}, {value: config.legend.unselectedOpacity}]; + } else if (opacity) { out.opacity = {value: opacity}; } } @@ -132,10 +135,12 @@ export function labels( fieldDef: TypedFieldDef, labelsSpec: any, model: UnitModel, - channel: NonPositionScaleChannel + channel: NonPositionScaleChannel, + legendCmp: LegendComponent ) { const legend = model.legend(channel); const config = model.config; + const condition = selectedCondition(model, legendCmp, fieldDef); let out: SymbolEncodeEntry = {}; @@ -155,11 +160,26 @@ export function labels( }; } + if (condition) { + labelsSpec.opacity = [{test: condition, value: 1}, {value: config.legend.unselectedOpacity}]; + } + out = {...out, ...labelsSpec}; return keys(out).length > 0 ? out : undefined; } +export function entries( + fieldDef: TypedFieldDef, + entriesSpec: any, + model: UnitModel, + channel: NonPositionScaleChannel, + legendCmp: LegendComponent +) { + const selections = legendCmp.get('selections'); + return selections?.length ? {fill: {value: 'transparent'}} : undefined; +} + function getMaxValue( channelDef: | FieldDefWithCondition, number> @@ -190,3 +210,16 @@ function getConditionValue( } return undefined; } + +function selectedCondition(model: UnitModel, legendCmp: LegendComponent, fieldDef: TypedFieldDef) { + const selections = legendCmp.get('selections'); + if (!selections?.length) return undefined; + + const field = stringValue(fieldDef.field); + return selections + .map(name => { + const store = stringValue(varName(name) + STORE); + return `(!length(data(${store})) || (${name}[${field}] && indexof(${name}[${field}], datum.value) >= 0))`; + }) + .join(' || '); +} diff --git a/src/compile/legend/parse.ts b/src/compile/legend/parse.ts index 9d298549d9..c9bd60787d 100644 --- a/src/compile/legend/parse.ts +++ b/src/compile/legend/parse.ts @@ -31,6 +31,7 @@ import {LegendComponent, LegendComponentIndex, LegendComponentProps, LEGEND_COMP import * as encode from './encode'; import * as properties from './properties'; import {direction, type} from './properties'; +import {parseInteractiveLegend} from '../selection/transforms/legends'; export function parseLegend(model: Model) { if (isUnitModel(model)) { @@ -91,6 +92,7 @@ export function parseLegendForChannel(model: UnitModel, channel: NonPositionScal const legend = model.legend(channel); const legendCmpt = new LegendComponent({}, getLegendDefWithScale(model, channel)); + parseInteractiveLegend(model, channel, legendCmpt); for (const property of LEGEND_COMPONENT_PROPERTIES) { const value = getProperty(property, legend, channel, model); @@ -103,14 +105,19 @@ export function parseLegendForChannel(model: UnitModel, channel: NonPositionScal } const legendEncoding = legend.encoding ?? {}; - const legendEncode = (['labels', 'legend', 'title', 'symbols', 'gradient'] as const).reduce( + const selections = legendCmpt.get('selections'); + const legendEncode = (['labels', 'legend', 'title', 'symbols', 'gradient', 'entries'] as const).reduce( (e: LegendEncode, part) => { const legendEncodingPart = guideEncodeEntry(legendEncoding[part] ?? {}, model); const value = encode[part] ? encode[part](fieldDef, legendEncodingPart, model, channel, legendCmpt) // apply rule : legendEncodingPart; // no rule -- just default values if (value !== undefined && keys(value).length > 0) { - e[part] = {update: value}; + e[part] = { + ...(selections?.length ? {name: `${fieldDef.field}_legend_${part}`} : {}), + ...(selections?.length ? {interactive: !!selections} : {}), + update: value + }; } return e; }, diff --git a/src/compile/mark/valueref.ts b/src/compile/mark/valueref.ts index 4884d328c1..b64693a9b0 100644 --- a/src/compile/mark/valueref.ts +++ b/src/compile/mark/valueref.ts @@ -443,7 +443,7 @@ export function tooltipForEncoding( } } - return keyValues.length ? {signal: `{${keyValues.join(', ')}}`} : undefined; + return keyValues.length > 0 ? {signal: `{${keyValues.join(', ')}}`} : undefined; } export function text( diff --git a/src/compile/selection/assemble.ts b/src/compile/selection/assemble.ts index 3a46dc45d2..54fa0ad4a6 100644 --- a/src/compile/selection/assemble.ts +++ b/src/compile/selection/assemble.ts @@ -76,7 +76,7 @@ export function assembleTopLevelSignals(model: UnitModel, signals: Signal[]) { const name = selCmpt.name; const store = stringValue(name + STORE); const hasSg = signals.filter(s => s.name === name); - if (!hasSg.length) { + if (hasSg.length === 0) { const resolve = selCmpt.resolve === 'global' ? 'union' : selCmpt.resolve; const isMulti = selCmpt.type === 'multi' ? ', true)' : ')'; signals.push({ @@ -99,7 +99,7 @@ export function assembleTopLevelSignals(model: UnitModel, signals: Signal[]) { if (hasSelections) { const hasUnit = signals.filter(s => s.name === 'unit'); - if (!hasUnit.length) { + if (hasUnit.length === 0) { signals.unshift({ name: 'unit', value: {}, diff --git a/src/compile/selection/index.ts b/src/compile/selection/index.ts index 23ec8017e1..b983a7bae5 100644 --- a/src/compile/selection/index.ts +++ b/src/compile/selection/index.ts @@ -7,7 +7,8 @@ import { SelectionInitInterval, SelectionResolution, SelectionType, - SELECTION_ID + SELECTION_ID, + LegendBinding } from '../../selection'; import {Dict} from '../../util'; import {FacetModel} from '../facet'; @@ -38,8 +39,7 @@ export interface SelectionComponent { : never)[]; events: Stream[]; materialized: OutputNode; - - bind?: 'scales' | Binding | Dict; + bind?: 'scales' | Binding | Dict | LegendBinding; resolve: SelectionResolution; empty: 'all' | 'none'; mark?: BrushConfig; @@ -65,14 +65,15 @@ const compilers: Dict = {single, multi, interval}; export function forEachSelection( model: Model, - cb: (selCmpt: SelectionComponent, selCompiler: SelectionCompiler) => void + cb: (selCmpt: SelectionComponent, selCompiler: SelectionCompiler) => void | boolean ) { const selections = model.component.selection; if (selections) { for (const name in selections) { if (hasOwnProperty(selections, name)) { const sel = selections[name]; - cb(sel, compilers[sel.type]); + const success = cb(sel, compilers[sel.type]); + if (success === true) break; } } } diff --git a/src/compile/selection/interval.ts b/src/compile/selection/interval.ts index 4f9668a4b3..f2fb24b4be 100644 --- a/src/compile/selection/interval.ts +++ b/src/compile/selection/interval.ts @@ -103,7 +103,7 @@ const interval: SelectionCompiler<'interval'> = { marks: (model, selCmpt, marks) => { const name = selCmpt.name; - const {x, y} = selCmpt.project.has; + const {x, y} = selCmpt.project.hasChannel; const xvname = x && x.signals.visual; const yvname = y && y.signals.visual; const store = `data(${stringValue(selCmpt.name + STORE)})`; diff --git a/src/compile/selection/multi.ts b/src/compile/selection/multi.ts index 47508fbd0c..7941c25263 100644 --- a/src/compile/selection/multi.ts +++ b/src/compile/selection/multi.ts @@ -1,4 +1,3 @@ -import {Signal} from 'vega'; import {stringValue} from 'vega-util'; import {SelectionCompiler, SelectionComponent, TUPLE, unitName} from '.'; import {UnitModel} from '../unit'; @@ -7,9 +6,9 @@ import {TUPLE_FIELDS} from './transforms/project'; export function singleOrMultiSignals(model: UnitModel, selCmpt: SelectionComponent<'single' | 'multi'>) { const name = selCmpt.name; const fieldsSg = name + TUPLE_FIELDS; - const proj = selCmpt.project; + const project = selCmpt.project; const datum = '(item().isVoronoi ? datum.datum : datum)'; - const values = proj.items + const values = project.items .map(p => { const fieldDef = model.fieldDef(p.channel); // Binned fields should capture extents, for a range test against the raw field. @@ -28,7 +27,7 @@ export function singleOrMultiSignals(model: UnitModel, selCmpt: SelectionCompone // whitespace followed by a click in whitespace; the store should only // be cleared on the second click). const update = `unit: ${unitName(model)}, fields: ${fieldsSg}, values`; - const signals: Signal[] = [ + return [ { name: name + TUPLE, on: selCmpt.events @@ -42,8 +41,6 @@ export function singleOrMultiSignals(model: UnitModel, selCmpt: SelectionCompone : [] } ]; - - return signals; } const multi: SelectionCompiler<'multi'> = { diff --git a/src/compile/selection/parse.ts b/src/compile/selection/parse.ts index 17d652edee..0ac1ba50dd 100644 --- a/src/compile/selection/parse.ts +++ b/src/compile/selection/parse.ts @@ -47,11 +47,11 @@ export function parseUnitSelection(model: UnitModel, selDefs: Dict const selCmpt = (selCmpts[safeName] = { ...selDef, name: safeName, - events: isString(selDef.on) ? parseSelector(selDef.on, 'scope') : selDef.on + events: isString(selDef.on) ? parseSelector(selDef.on, 'scope') : duplicate(selDef.on) } as any); forEachTransform(selCmpt, txCompiler => { - if (txCompiler.parse) { + if (txCompiler.has(selCmpt) && txCompiler.parse) { txCompiler.parse(model, selCmpt, selDef, selDefs[name]); } }); @@ -63,7 +63,8 @@ export function parseUnitSelection(model: UnitModel, selDefs: Dict export function parseSelectionPredicate( model: Model, selections: LogicalOperand, - dfnode?: DataFlowNode + dfnode?: DataFlowNode, + datum = 'datum' ): string { const stores: string[] = []; function expr(name: string): string { @@ -86,7 +87,7 @@ export function parseSelectionPredicate( } return ( - `vlSelectionTest(${store}, datum` + (selCmpt.resolve === 'global' ? ')' : `, ${stringValue(selCmpt.resolve)})`) + `vlSelectionTest(${store}, ${datum}` + (selCmpt.resolve === 'global' ? ')' : `, ${stringValue(selCmpt.resolve)})`) ); } diff --git a/src/compile/selection/transforms/inputs.ts b/src/compile/selection/transforms/inputs.ts index ac51204af7..ae87fecbef 100644 --- a/src/compile/selection/transforms/inputs.ts +++ b/src/compile/selection/transforms/inputs.ts @@ -5,10 +5,17 @@ import {assembleInit} from '../assemble'; import nearest from './nearest'; import {TUPLE_FIELDS} from './project'; import {TransformCompiler} from './transforms'; +import {isLegendBinding} from '../../../selection'; const inputBindings: TransformCompiler = { has: selCmpt => { - return selCmpt.type === 'single' && selCmpt.resolve === 'global' && selCmpt.bind && selCmpt.bind !== 'scales'; + return ( + selCmpt.type === 'single' && + selCmpt.resolve === 'global' && + selCmpt.bind && + selCmpt.bind !== 'scales' && + !isLegendBinding(selCmpt.bind) + ); }, parse: (model, selCmpt, selDef, origDef) => { @@ -28,6 +35,7 @@ const inputBindings: TransformCompiler = { proj.items.forEach((p, i) => { const sgname = varName(`${name}_${p.field}`); const hasSignal = signals.filter(s => s.name === sgname); + if (!hasSignal.length) { signals.unshift({ name: sgname, diff --git a/src/compile/selection/transforms/legends.ts b/src/compile/selection/transforms/legends.ts new file mode 100644 index 0000000000..732d2a4f90 --- /dev/null +++ b/src/compile/selection/transforms/legends.ts @@ -0,0 +1,131 @@ +import {selector as parseSelector} from 'vega-event-selector'; +import {TransformCompiler} from './transforms'; +import {UnitModel} from '../../unit'; +import {NonPositionScaleChannel} from '../../../channel'; +import {LegendComponent} from '../../legend/component'; +import {forEachSelection, SelectionComponent, TUPLE} from '..'; +import {array, isString} from 'vega-util'; +import {Stream, MergedStream} from 'vega'; +import {SELECTION_ID, isLegendBinding, isLegendStreamBinding} from '../../../selection'; +import * as log from '../../../log'; +import {duplicate, varName} from '../../../util'; +import {TUPLE_FIELDS} from './project'; +import {TOGGLE} from './toggle'; + +const legendBindings: TransformCompiler = { + has: (selCmpt: SelectionComponent<'single' | 'multi'>) => { + const spec = selCmpt.resolve === 'global' && selCmpt.bind && isLegendBinding(selCmpt.bind); + const projLen = selCmpt.project.items.length === 1 && selCmpt.project.items[0].field !== SELECTION_ID; + if (spec && !projLen) { + log.warn(log.message.LEGEND_BINDINGS_PROJECT_LENGTH); + } + + return spec && projLen; + }, + + parse: (model, selCmpt, selDef, origDef) => { + // Binding a selection to a legend disables default direct manipulation interaction. + // A user can choose to re-enable it by explicitly specifying triggering input events. + if (!origDef.on) delete selCmpt.events; + if (!origDef.clear) delete selCmpt.clear; + + if (origDef.on || origDef.clear) { + const legendFilter = 'event.item && indexof(event.item.mark.role, "legend") < 0'; + for (const evt of selCmpt.events) { + evt.filter = array(evt.filter ?? []); + if (evt.filter.indexOf(legendFilter) < 0) { + evt.filter.push(legendFilter); + } + } + } + + const evt = isLegendStreamBinding(selCmpt.bind) ? selCmpt.bind.legend : 'click'; + const stream: Stream[] = isString(evt) ? parseSelector(evt, 'view') : array(evt); + selCmpt.bind = {legend: {merge: stream}}; + }, + + topLevelSignals: (model, selCmpt: SelectionComponent<'single' | 'multi'>, signals) => { + const selName = selCmpt.name; + const stream = isLegendStreamBinding(selCmpt.bind) && (selCmpt.bind.legend as MergedStream); + const markName = (name: string) => (s: Stream) => { + const ds = duplicate(s); + ds.markname = name; + return ds; + }; + + for (const proj of selCmpt.project.items) { + if (!proj.hasLegend) continue; + const prefix = `${proj.field}_legend`; + const sgName = `${selName}_${prefix}`; + const hasSignal = signals.filter(s => s.name === sgName); + + if (hasSignal.length === 0) { + const events = stream.merge + .map(markName(`${prefix}_symbols`)) + .concat(stream.merge.map(markName(`${prefix}_labels`))) + .concat(stream.merge.map(markName(`${prefix}_entries`))); + + signals.unshift({ + name: sgName, + ...(!selCmpt.init ? {value: null} : {}), + on: [ + // Legend entries do not store values, so we need to walk the scenegraph to the symbol datum. + {events, update: 'datum.value || item().items[0].items[0].datum.value', force: true}, + {events: stream.merge, update: `!event.item || !datum ? null : ${sgName}`, force: true} + ] + }); + } + } + + return signals; + }, + + signals: (model, selCmpt, signals) => { + const name = selCmpt.name; + const proj = selCmpt.project; + const tuple = signals.find(s => s.name === name + TUPLE); + const fields = name + TUPLE_FIELDS; + const values = proj.items.filter(p => p.hasLegend).map(p => varName(`${name}_${p.field}_legend`)); + const valid = values.map(v => `${v} !== null`).join(' && '); + const update = `${valid} ? {fields: ${fields}, values: [${values.join(', ')}]} : null`; + + if (selCmpt.events && values.length > 0) { + tuple.on.push({ + events: values.map(signal => ({signal})), + update + }); + } else if (values.length > 0) { + tuple.update = update; + delete tuple.value; + delete tuple.on; + } + + const toggle = signals.find(s => s.name === name + TOGGLE); + const events = isLegendStreamBinding(selCmpt.bind) && selCmpt.bind.legend; + if (toggle) { + if (!selCmpt.events) toggle.on[0].events = events; + else toggle.on.push({...toggle.on[0], events}); + } + + return signals; + } +}; + +export default legendBindings; + +export function parseInteractiveLegend( + model: UnitModel, + channel: NonPositionScaleChannel, + legendCmpt: LegendComponent +) { + const field = model.fieldDef(channel).field; + forEachSelection(model, selCmpt => { + const proj = selCmpt.project.hasField[field] ?? selCmpt.project.hasChannel[channel]; + if (proj && legendBindings.has(selCmpt)) { + const legendSelections = legendCmpt.get('selections') ?? []; + legendSelections.push(selCmpt.name); + legendCmpt.set('selections', legendSelections, false); + proj.hasLegend = true; + } + }); +} diff --git a/src/compile/selection/transforms/nearest.ts b/src/compile/selection/transforms/nearest.ts index 6be9f736a7..9f632d2a6a 100644 --- a/src/compile/selection/transforms/nearest.ts +++ b/src/compile/selection/transforms/nearest.ts @@ -21,7 +21,7 @@ const nearest: TransformCompiler = { }, marks: (model, selCmpt, marks) => { - const {x, y} = selCmpt.project.has; + const {x, y} = selCmpt.project.hasChannel; const markType = model.mark; if (isPathMark(markType)) { log.warn(log.message.nearestNotSupportForContinuous(markType)); diff --git a/src/compile/selection/transforms/project.ts b/src/compile/selection/transforms/project.ts index 99808c773f..ae8743e388 100644 --- a/src/compile/selection/transforms/project.ts +++ b/src/compile/selection/transforms/project.ts @@ -20,16 +20,19 @@ export interface SelectionProjection { field: string; channel?: SingleDefUnitChannel; signals?: {data?: string; visual?: string}; + hasLegend?: boolean; } export class SelectionProjectionComponent { - public has: {[key in SingleDefUnitChannel]?: SelectionProjection}; + public hasChannel: {[key in SingleDefUnitChannel]?: SelectionProjection}; + public hasField: {[k: string]: SelectionProjection}; public timeUnit?: TimeUnitNode; public items: SelectionProjection[]; constructor(...items: SelectionProjection[]) { this.items = items; - this.has = {}; + this.hasChannel = {}; + this.hasField = {}; } } @@ -87,12 +90,22 @@ const project: TransformCompiler = { const p: SelectionProjection = {type: 'E', field}; p.signals = {...signalName(p, 'data')}; proj.items.push(p); + proj.hasField[field] = p; } for (const channel of selDef.encodings ?? []) { const fieldDef = model.fieldDef(channel); if (fieldDef) { let field = fieldDef.field; + + if (fieldDef.aggregate) { + log.warn(log.message.cannotProjectAggregate(channel, fieldDef.aggregate)); + continue; + } else if (!field) { + log.warn(log.message.cannotProjectOnChannelWithoutField(channel)); + continue; + } + if (fieldDef.timeUnit) { field = model.vgField(channel); @@ -127,7 +140,7 @@ const project: TransformCompiler = { const p: SelectionProjection = {field, channel, type}; p.signals = {...signalName(p, 'data'), ...signalName(p, 'visual')}; proj.items.push((parsed[field] = p)); - proj.has[channel] = parsed[field]; + proj.hasField[field] = proj.hasChannel[channel] = parsed[field]; } } else { log.warn(log.message.cannotProjectOnChannelWithoutField(channel)); @@ -147,7 +160,7 @@ const project: TransformCompiler = { } } - if (keys(timeUnits).length) { + if (keys(timeUnits).length > 0) { proj.timeUnit = new TimeUnitNode(null, timeUnits); } }, @@ -155,12 +168,12 @@ const project: TransformCompiler = { signals: (model, selCmpt, allSignals) => { const name = selCmpt.name + TUPLE_FIELDS; const hasSignal = allSignals.filter(s => s.name === name); - return hasSignal.length + return hasSignal.length > 0 ? allSignals : allSignals.concat({ name, value: selCmpt.project.items.map(proj => { - const {signals, ...rest} = proj; + const {signals, hasLegend, ...rest} = proj; const p = duplicate(rest); p.field = replacePathInField(p.field); return p; diff --git a/src/compile/selection/transforms/scales.ts b/src/compile/selection/transforms/scales.ts index af3af6d184..2f889ef395 100644 --- a/src/compile/selection/transforms/scales.ts +++ b/src/compile/selection/transforms/scales.ts @@ -44,11 +44,11 @@ const scaleBindings: TransformCompiler = { }, topLevelSignals: (model, selCmpt, signals) => { - const bound = selCmpt.scales.filter(proj => !signals.filter(s => s.name === proj.signals.data).length); + const bound = selCmpt.scales.filter(proj => signals.filter(s => s.name === proj.signals.data).length === 0); // Top-level signals are only needed for multiview displays and if this // view's top-level signals haven't already been generated. - if (!model.parent || isTopLevelLayer(model) || !bound.length) { + if (!model.parent || isTopLevelLayer(model) || bound.length === 0) { return signals; } diff --git a/src/compile/selection/transforms/toggle.ts b/src/compile/selection/transforms/toggle.ts index 5764c7e31d..5adc5142bc 100644 --- a/src/compile/selection/transforms/toggle.ts +++ b/src/compile/selection/transforms/toggle.ts @@ -5,7 +5,7 @@ export const TOGGLE = '_toggle'; const toggle: TransformCompiler = { has: selCmpt => { - return selCmpt.type === 'multi' && selCmpt.toggle; + return selCmpt.type === 'multi' && !!selCmpt.toggle; }, signals: (model, selCmpt, signals) => { diff --git a/src/compile/selection/transforms/transforms.ts b/src/compile/selection/transforms/transforms.ts index 585be4a338..fbf528bf1d 100644 --- a/src/compile/selection/transforms/transforms.ts +++ b/src/compile/selection/transforms/transforms.ts @@ -8,12 +8,13 @@ import inputs from './inputs'; import nearest from './nearest'; import project from './project'; import scales from './scales'; +import legends from './legends'; import toggle from './toggle'; import translate from './translate'; import zoom from './zoom'; export interface TransformCompiler { - has: (selCmpt: SelectionComponent | SelectionDef) => boolean; + has: (selCmpt: SelectionComponent) => boolean; parse?: (model: UnitModel, selCmpt: SelectionComponent, def: SelectionDef, origDef: SelectionDef) => void; signals?: (model: UnitModel, selCmpt: SelectionComponent, signals: NewSignal[]) => Signal[]; // the output can be a new or a push signal topLevelSignals?: (model: Model, selCmpt: SelectionComponent, signals: NewSignal[]) => NewSignal[]; @@ -21,7 +22,7 @@ export interface TransformCompiler { marks?: (model: UnitModel, selCmpt: SelectionComponent, marks: any[]) => any[]; } -const compilers: TransformCompiler[] = [project, toggle, scales, translate, zoom, inputs, nearest, clear]; +const compilers: TransformCompiler[] = [project, toggle, scales, legends, translate, zoom, inputs, nearest, clear]; export function forEachTransform(selCmpt: SelectionComponent, cb: (tx: TransformCompiler) => void) { for (const t of compilers) { diff --git a/src/compile/selection/transforms/translate.ts b/src/compile/selection/transforms/translate.ts index 265a06ffe1..7ab0794eb2 100644 --- a/src/compile/selection/transforms/translate.ts +++ b/src/compile/selection/transforms/translate.ts @@ -20,7 +20,7 @@ const translate: TransformCompiler = { const name = selCmpt.name; const hasScales = scalesCompiler.has(selCmpt); const anchor = name + ANCHOR; - const {x, y} = selCmpt.project.has; + const {x, y} = selCmpt.project.hasChannel; let events = parseSelector(selCmpt.translate, 'scope'); if (!hasScales) { diff --git a/src/compile/selection/transforms/zoom.ts b/src/compile/selection/transforms/zoom.ts index b99cf728aa..1fc4760bd0 100644 --- a/src/compile/selection/transforms/zoom.ts +++ b/src/compile/selection/transforms/zoom.ts @@ -21,7 +21,7 @@ const zoom: TransformCompiler = { const name = selCmpt.name; const hasScales = scalesCompiler.has(selCmpt); const delta = name + DELTA; - const {x, y} = selCmpt.project.has; + const {x, y} = selCmpt.project.hasChannel; const sx = stringValue(model.scaleName(X)); const sy = stringValue(model.scaleName(Y)); let events = parseSelector(selCmpt.zoom, 'scope'); diff --git a/src/compile/split.ts b/src/compile/split.ts index 80eb695893..e681d93df2 100644 --- a/src/compile/split.ts +++ b/src/compile/split.ts @@ -1,5 +1,5 @@ import * as log from '../log'; -import {duplicate, getFirstDefined, keys, stringify} from '../util'; +import {deepEqual, duplicate, getFirstDefined, keys} from '../util'; /** * Generic class for storing properties that are explicitly specified @@ -149,7 +149,7 @@ export function mergeValuesWithExplicit( return v1; } else if (v2.explicit && !v1.explicit) { return v2; - } else if (stringify(v1.value) === stringify(v2.value)) { + } else if (deepEqual(v1.value, v2.value)) { return v1; } else { return tieBreaker(v1, v2, property, propertyOf); diff --git a/src/compositemark/errorbar.ts b/src/compositemark/errorbar.ts index bd90a4b252..22ac0c84a5 100644 --- a/src/compositemark/errorbar.ts +++ b/src/compositemark/errorbar.ts @@ -375,7 +375,7 @@ export function errorBarParams< ...(outerSpec.transform ?? []), ...bins, ...timeUnits, - ...(!aggregate.length ? [] : [{aggregate, groupby}]), + ...(aggregate.length === 0 ? [] : [{aggregate, groupby}]), ...postAggregateCalculates ], groupby, diff --git a/src/guide.ts b/src/guide.ts index c384f60974..6ac492cbd3 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -72,5 +72,6 @@ export const VL_ONLY_LEGEND_CONFIG: (keyof LegendConfig)[] = [ 'gradientHorizontalMaxLength', 'gradientHorizontalMinLength', 'gradientVerticalMaxLength', - 'gradientVerticalMinLength' + 'gradientVerticalMinLength', + 'unselectedOpacity' ]; diff --git a/src/legend.ts b/src/legend.ts index 3175fb0956..1f60969080 100644 --- a/src/legend.ts +++ b/src/legend.ts @@ -56,6 +56,13 @@ export type LegendConfig = LegendMixins & * @minimum 0 */ gradientLength?: number; + + /** + * The opacity of unselected legend entries. + * + * __Default value:__ 0.35. + */ + unselectedOpacity?: number; }; /** @@ -165,7 +172,8 @@ export const defaultLegendConfig: LegendConfig = { gradientHorizontalMaxLength: 200, gradientHorizontalMinLength: 100, gradientVerticalMaxLength: 200, - gradientVerticalMinLength: 64 // This is the Vega's minimum. + gradientVerticalMinLength: 64, // This is Vega's minimum. + unselectedOpacity: 0.35 }; export const COMMON_LEGEND_PROPERTY_INDEX: Flag = { diff --git a/src/log/message.ts b/src/log/message.ts index 9cd7703c60..60f3a5fd0c 100644 --- a/src/log/message.ts +++ b/src/log/message.ts @@ -1,7 +1,7 @@ import {AggregateOp} from 'vega'; import {Aggregate} from '../aggregate'; import {Channel, FacetChannel, GeoPositionChannel, getSizeType, PositionScaleChannel} from '../channel'; -import {TypedFieldDef, Value} from '../channeldef'; +import {TypedFieldDef, Value, HiddenCompositeAggregate} from '../channeldef'; import {SplitParentProperty} from '../compile/split'; import {CompositeMark} from '../compositemark'; import {ErrorBarCenter, ErrorBarExtent} from '../compositemark/errorbar'; @@ -48,6 +48,10 @@ export function cannotProjectOnChannelWithoutField(channel: Channel) { return `Cannot project a selection on encoding channel "${channel}", which has no field.`; } +export function cannotProjectAggregate(channel: Channel, aggregate: Aggregate | HiddenCompositeAggregate) { + return `Cannot project a selection on encoding channel "${channel}" as it uses an aggregate function ("${aggregate}").`; +} + export function nearestNotSupportForContinuous(mark: string) { return `The "nearest" transform is not supported for ${mark} marks.`; } @@ -63,6 +67,8 @@ export function selectionNotFound(name: string) { export const SCALE_BINDINGS_CONTINUOUS = 'Scale bindings are currently only supported for scales with unbinned, continuous domains.'; +export const LEGEND_BINDINGS_PROJECT_LENGTH = + 'Legend bindings are only supported for selections over an individual field or encoding channel.'; export function noSameUnitLookup(name: string) { return ( `Cannot define and lookup the "${name}" selection in the same view. ` + diff --git a/src/normalize/rangestep.ts b/src/normalize/rangestep.ts index c30a2891db..7962501a34 100644 --- a/src/normalize/rangestep.ts +++ b/src/normalize/rangestep.ts @@ -47,7 +47,7 @@ export class RangeStepNormalizer implements NonFacetUnitNormalizer 0 ? {scale: scaleWithoutRangeStep} : {}) } }; } diff --git a/src/selection.ts b/src/selection.ts index 5156156b01..57b222b1a7 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -1,4 +1,5 @@ import {Binding, Color, Stream, Vector2} from 'vega'; +import {isObject} from 'vega-util'; import {SingleDefUnitChannel} from './channel'; import {FieldName, Value} from './channeldef'; import {DateTime} from './datetime'; @@ -14,6 +15,9 @@ export type SelectionInitInterval = Vector2 | Vector2 | Vector2 export type SelectionInitMapping = Dict; export type SelectionInitIntervalMapping = Dict; +export type LegendStreamBinding = {legend: string | Stream}; +export type LegendBinding = 'legend' | LegendStreamBinding; + export interface BaseSelectionConfig { /** * Clears the selection, emptying it of all values. Can be a @@ -68,14 +72,18 @@ export interface BaseSelectionConfig { export interface SingleSelectionConfig extends BaseSelectionConfig { /** - * Establish a two-way binding between a single selection and input elements - * (also known as dynamic query widgets). A binding takes the form of - * Vega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind) + * When set, a selection is populated by input elements (also known as dynamic query widgets) + * or by interacting with the corresponding legend. Direct manipulation interaction is disabled by default; + * to re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property. + * + * Legend bindings are restricted to selections that only specify a single field or encoding. + * + * Query widget binding takes the form of Vega's [input element binding definition](https://vega.github.io/vega/docs/signals/#bind) * or can be a mapping between projected field/encodings and binding definitions. * * __See also:__ [`bind`](https://vega.github.io/vega-lite/docs/bind.html) documentation. */ - bind?: Binding | {[key: string]: Binding}; + bind?: Binding | {[key: string]: Binding} | LegendBinding; /** * When true, an invisible voronoi diagram is computed to accelerate discrete @@ -121,6 +129,14 @@ export interface MultiSelectionConfig extends BaseSelectionConfig { * __See also:__ [`init`](https://vega.github.io/vega-lite/docs/init.html) documentation. */ init?: SelectionInitMapping[]; + + /** + * When set, a selection is populated by interacting with the corresponding legend. Direct manipulation interaction is disabled by default; + * to re-enable it, set the selection's [`on`](https://vega.github.io/vega-lite/docs/selection.html#common-selection-properties) property. + * + * Legend bindings are restricted to selections that only specify a single field or encoding. + */ + bind?: LegendBinding; } export interface BrushConfig { @@ -311,3 +327,11 @@ export const defaultConfig: SelectionConfig = { clear: 'dblclick' } }; + +export function isLegendBinding(bind: any): bind is LegendBinding { + return !!bind && (bind === 'legend' || !!bind.legend); +} + +export function isLegendStreamBinding(bind: any): bind is LegendStreamBinding { + return isLegendBinding(bind) && isObject(bind); +} diff --git a/src/transformextract.ts b/src/transformextract.ts index 7db45e33d7..a0c47b0890 100644 --- a/src/transformextract.ts +++ b/src/transformextract.ts @@ -14,7 +14,7 @@ class TransformExtractMapper extends SpecMapper<{config: Config}, GenericUnitSpe ...(oldTransforms ? oldTransforms : []), ...bins, ...timeUnits, - ...(!aggregate.length ? [] : [{aggregate, groupby}]) + ...(aggregate.length === 0 ? [] : [{aggregate, groupby}]) ]; return { diff --git a/test/compile/legend/encode.test.ts b/test/compile/legend/encode.test.ts index 7929a4d9f2..a42fe61344 100644 --- a/test/compile/legend/encode.test.ts +++ b/test/compile/legend/encode.test.ts @@ -119,7 +119,7 @@ describe('compile/legend', () => { }); const fieldDef = {field: 'a', type: TEMPORAL, timeUnit: TimeUnit.MONTH}; - const label = encode.labels(fieldDef, {}, model, COLOR); + const label = encode.labels(fieldDef, {}, model, COLOR, symbolLegend); const expected = `timeFormat(datum.value, '%b')`; expect((label.text as SignalRef).signal).toEqual(expected); }); @@ -134,7 +134,7 @@ describe('compile/legend', () => { }); const fieldDef = {field: 'a', type: TEMPORAL, timeUnit: TimeUnit.QUARTER}; - const label = encode.labels(fieldDef, {}, model, COLOR); + const label = encode.labels(fieldDef, {}, model, COLOR, symbolLegend); const expected = `'Q' + quarter(datum.value)`; expect((label.text as SignalRef).signal).toEqual(expected); }); diff --git a/test/compile/selection/inputs.test.ts b/test/compile/selection/inputs.test.ts index 9735021d8d..9ac9650512 100644 --- a/test/compile/selection/inputs.test.ts +++ b/test/compile/selection/inputs.test.ts @@ -74,6 +74,7 @@ describe('Inputs Selection Transform', () => { on: 'click', bind: {input: 'range', min: 0, max: 10, step: 1} }, + twelve: {type: 'single', bind: 'legend'}, 'space separated': { type: 'single', bind: {input: 'range', min: 0, max: 10, step: 1} @@ -95,6 +96,7 @@ describe('Inputs Selection Transform', () => { expect(inputs.has(selCmpts['nine'])).toBeTruthy(); expect(inputs.has(selCmpts['ten'])).toBeTruthy(); expect(inputs.has(selCmpts['eleven'])).toBeTruthy(); + expect(inputs.has(selCmpts['twelve'])).toBeFalsy(); expect(inputs.has(selCmpts['space_separated'])).toBeTruthy(); expect(inputs.has(selCmpts['dash_separated'])).toBeTruthy(); }); diff --git a/test/compile/selection/legends.test.ts b/test/compile/selection/legends.test.ts new file mode 100644 index 0000000000..1b2837c925 --- /dev/null +++ b/test/compile/selection/legends.test.ts @@ -0,0 +1,394 @@ +import {parseUnitModel} from '../../util'; +import {parseUnitSelection} from '../../../src/compile/selection/parse'; +import {assembleTopLevelSignals, assembleUnitSelectionSignals} from '../../../src/compile/selection/assemble'; +import legends from '../../../src/compile/selection/transforms/legends'; +import * as log from '../../../src/log'; + +describe('Interactive Legends', () => { + const model = parseUnitModel({ + mark: 'circle', + encoding: { + x: {field: 'Horsepower', type: 'quantitative'}, + y: {field: 'Miles_per_Gallon', type: 'quantitative'}, + color: {field: 'Origin', type: 'nominal'}, + size: {field: 'Cylinders', type: 'ordinal'} + } + }); + + model.parseScale(); + const selCmpts = (model.component.selection = parseUnitSelection(model, { + one: {type: 'single', fields: ['Origin'], bind: 'legend'}, + two: {type: 'multi', fields: ['Origin'], bind: {legend: 'dblclick, mouseover'}}, + three: {type: 'multi', fields: ['Origin', 'Cylinders'], bind: 'legend'}, + four: {type: 'single', encodings: ['color'], bind: 'legend'}, + five: {type: 'single', encodings: ['color', 'size'], bind: 'legend'}, + six: {type: 'multi', bind: 'legend'}, + seven: {type: 'multi', fields: ['Origin'], bind: {legend: 'mouseover'}, on: 'click'}, + eight: {type: 'multi', encodings: ['color'], bind: {legend: 'mouseover'}, on: 'click', clear: 'dblclick'}, + nine: { + type: 'single', + bind: {input: 'range', min: 0, max: 10, step: 1} + }, + ten: { + type: 'multi', + fields: ['Origin'], + bind: 'legend', + init: [{Origin: 'USA'}, {Origin: 'Japan'}] + } + })); + model.parseLegends(); + + it('identifies transform invocation', () => { + log.wrap(localLogger => { + expect(legends.has(selCmpts['one'])).toBeTruthy(); + expect(legends.has(selCmpts['two'])).toBeTruthy(); + + expect(legends.has(selCmpts['three'])).toBeFalsy(); + expect(localLogger.warns[0]).toEqual(log.message.LEGEND_BINDINGS_PROJECT_LENGTH); + + expect(legends.has(selCmpts['four'])).toBeTruthy(); + + expect(legends.has(selCmpts['five'])).toBeFalsy(); + expect(localLogger.warns[1]).toEqual(log.message.LEGEND_BINDINGS_PROJECT_LENGTH); + + expect(legends.has(selCmpts['six'])).toBeFalsy(); + expect(localLogger.warns[2]).toEqual(log.message.LEGEND_BINDINGS_PROJECT_LENGTH); + + expect(legends.has(selCmpts['seven'])).toBeTruthy(); + expect(legends.has(selCmpts['eight'])).toBeTruthy(); + expect(legends.has(selCmpts['nine'])).toBeFalsy(); + expect(legends.has(selCmpts['ten'])).toBeTruthy(); + }); + }); + + it('adds legend binding top-level signal', () => { + expect(assembleTopLevelSignals(model, [])).toEqual( + expect.arrayContaining([ + { + name: 'one_Origin_legend', + value: null, + on: [ + { + events: [ + { + source: 'view', + type: 'click', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [{source: 'view', type: 'click'}], + update: '!event.item || !datum ? null : one_Origin_legend', + force: true + } + ] + }, + { + name: 'two_Origin_legend', + value: null, + on: [ + { + events: [ + { + source: 'view', + type: 'dblclick', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'dblclick', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'dblclick', + markname: 'Origin_legend_entries' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [ + { + source: 'view', + type: 'dblclick' + }, + { + source: 'view', + type: 'mouseover' + } + ], + update: '!event.item || !datum ? null : two_Origin_legend', + force: true + } + ] + }, + { + name: 'four_Origin_legend', + value: null, + on: [ + { + events: [ + { + source: 'view', + type: 'click', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [ + { + source: 'view', + type: 'click' + } + ], + update: '!event.item || !datum ? null : four_Origin_legend', + force: true + } + ] + }, + { + name: 'seven_Origin_legend', + value: null, + on: [ + { + events: [ + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [ + { + source: 'view', + type: 'mouseover' + } + ], + update: '!event.item || !datum ? null : seven_Origin_legend', + force: true + } + ] + }, + { + name: 'eight_Origin_legend', + value: null, + on: [ + { + events: [ + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'mouseover', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [ + { + source: 'view', + type: 'mouseover' + } + ], + update: '!event.item || !datum ? null : eight_Origin_legend', + force: true + } + ] + } + ]) + ); + }); + + it('updates tuple signal to use bound top-level signal', () => { + expect(assembleUnitSelectionSignals(model, [])).toEqual( + expect.arrayContaining([ + { + name: 'one_tuple', + update: 'one_Origin_legend !== null ? {fields: one_tuple_fields, values: [one_Origin_legend]} : null' + }, + { + name: 'two_tuple', + update: 'two_Origin_legend !== null ? {fields: two_tuple_fields, values: [two_Origin_legend]} : null' + }, + { + name: 'four_tuple', + update: 'four_Origin_legend !== null ? {fields: four_tuple_fields, values: [four_Origin_legend]} : null' + } + ]) + ); + }); + + it('preserves explicit event triggers', () => { + expect(assembleUnitSelectionSignals(model, [])).toEqual( + expect.arrayContaining([ + { + name: 'seven_tuple', + on: [ + { + events: [ + { + source: 'scope', + type: 'click', + filter: ['event.item && indexof(event.item.mark.role, "legend") < 0'] + } + ], + update: + 'datum && item().mark.marktype !== \'group\' ? {unit: "", fields: seven_tuple_fields, values: [(item().isVoronoi ? datum.datum : datum)["Origin"]]} : null', + force: true + }, + { + events: [ + { + signal: 'seven_Origin_legend' + } + ], + update: + 'seven_Origin_legend !== null ? {fields: seven_tuple_fields, values: [seven_Origin_legend]} : null' + } + ] + }, + { + name: 'eight_tuple', + on: [ + { + events: [ + { + source: 'scope', + type: 'click', + filter: ['event.item && indexof(event.item.mark.role, "legend") < 0'] + } + ], + update: + 'datum && item().mark.marktype !== \'group\' ? {unit: "", fields: eight_tuple_fields, values: [(item().isVoronoi ? datum.datum : datum)["Origin"]]} : null', + force: true + }, + { + events: [ + { + signal: 'eight_Origin_legend' + } + ], + update: + 'eight_Origin_legend !== null ? {fields: eight_tuple_fields, values: [eight_Origin_legend]} : null' + }, + { + events: [ + { + source: 'scope', + type: 'dblclick' + } + ], + update: 'null' + } + ] + } + ]) + ); + }); + + it('respects initialization', () => { + expect(assembleTopLevelSignals(model, [])).toEqual( + expect.arrayContaining([ + { + name: 'ten_Origin_legend', + on: [ + { + events: [ + { + source: 'view', + type: 'click', + markname: 'Origin_legend_symbols' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_labels' + }, + { + source: 'view', + type: 'click', + markname: 'Origin_legend_entries' + } + ], + update: 'datum.value || item().items[0].items[0].datum.value', + force: true + }, + { + events: [{source: 'view', type: 'click'}], + update: '!event.item || !datum ? null : ten_Origin_legend', + force: true + } + ] + } + ]) + ); + }); +});