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