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 @@
+
\ 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 @@
+
\ 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
+ }
+ ]
+ }
+ ])
+ );
+ });
+});