Skip to content

Commit 89108f0

Browse files
arvinddomoritz
andauthored
feat: interactive legends (#5305)
* Interactive Legend * Correct tests * Enable interactive legends for multi-field selection * Support single type selections * Add tests for interactive legends * Simplify flagging a legend to be interactive if selection present. * Simplify signal assembly for interactive legends. * Simplify conditional encoding for legend items. * Scope legend events to "view" as signals may be nested within subscopes. * Move opacity of unselected legend entries to config. * Use more relaxed predicate to correctly select legend items. This is necessary when a selection is projected over several fields. Using vlSelectionTest will cause all legend items to appear unselected, even if one of the values is within in the selection. Instead, we now test only for the legend item itself, via the top-level resolved signal. * Encapsulate interactive legends in a selection transform. Augmenting existing signal logic with legend events yields buggy behavior when toggling values from the store based on unit name. It is also less efficient in multi-view cases, as each individual view would modify the store based on legend events. Instead, the legend selection transform adds a top-level signal to coordinate all legends for a given selection. Moreover, it allows users to selectively disable interactive legend processing (i.e., "legends": false in a selection definition). * Input widgets should reflect selection status from interactive legends. * Update existing compile-time unit tests. * Snapshot. * Fix build. * Simplify interactive legends by treating it as an alternate binding. * Add interactive legend example. * Update unit tests. * Fix bug with legend symbol opacity. * Add docs for interactive legends. * refactor: simplify logic * chore: update schema * docs: add missing docs for unselectedOpacity * chore: build stuff * chore: rebuild toc * fix: don't output unselectedOpacity in config * refactor: don't interpret numbers as truth values * chore: update examples [CI] * Only name and flag parts as interactive if selections exist on component * React to legend entries (whitespace between symbol, label). * Warn + skip if projecting over an aggregate field (or no field). * chore: update examples [CI] * chore: rebuild runtime examples * fix: resolve issue with symbol and color legend by using deep equal * fix: store selection names (not components) on legend component * refactor: use null coalescing Co-Authored-By: Dominik Moritz <[email protected]>
1 parent 4d1fc2d commit 89108f0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1412
-64
lines changed

build/vega-lite-schema.json

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9449,6 +9449,19 @@
94499449
},
94509450
"type": "object"
94519451
},
9452+
"LegendBinding": {
9453+
"anyOf": [
9454+
{
9455+
"enum": [
9456+
"legend"
9457+
],
9458+
"type": "string"
9459+
},
9460+
{
9461+
"$ref": "#/definitions/LegendStreamBinding"
9462+
}
9463+
]
9464+
},
94529465
"LegendConfig": {
94539466
"additionalProperties": false,
94549467
"properties": {
@@ -9814,6 +9827,10 @@
98149827
"titlePadding": {
98159828
"description": "The padding, in pixels, between title and legend.\n\n__Default value:__ `5`.",
98169829
"type": "number"
9830+
},
9831+
"unselectedOpacity": {
9832+
"description": "The opacity of unselected legend entries.\n\n__Default value:__ 0.35.",
9833+
"type": "number"
98179834
}
98189835
},
98199836
"type": "object"
@@ -9954,6 +9971,25 @@
99549971
},
99559972
"type": "object"
99569973
},
9974+
"LegendStreamBinding": {
9975+
"additionalProperties": false,
9976+
"properties": {
9977+
"legend": {
9978+
"anyOf": [
9979+
{
9980+
"type": "string"
9981+
},
9982+
{
9983+
"$ref": "#/definitions/Stream"
9984+
}
9985+
]
9986+
}
9987+
},
9988+
"required": [
9989+
"legend"
9990+
],
9991+
"type": "object"
9992+
},
99579993
"LineConfig": {
99589994
"additionalProperties": false,
99599995
"properties": {
@@ -11415,6 +11451,10 @@
1141511451
"MultiSelection": {
1141611452
"additionalProperties": false,
1141711453
"properties": {
11454+
"bind": {
11455+
"$ref": "#/definitions/LegendBinding",
11456+
"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."
11457+
},
1141811458
"clear": {
1141911459
"anyOf": [
1142011460
{
@@ -11500,6 +11540,10 @@
1150011540
"MultiSelectionConfig": {
1150111541
"additionalProperties": false,
1150211542
"properties": {
11543+
"bind": {
11544+
"$ref": "#/definitions/LegendBinding",
11545+
"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."
11546+
},
1150311547
"clear": {
1150411548
"anyOf": [
1150511549
{
@@ -14067,9 +14111,12 @@
1406714111
"$ref": "#/definitions/Binding"
1406814112
},
1406914113
"type": "object"
14114+
},
14115+
{
14116+
"$ref": "#/definitions/LegendBinding"
1407014117
}
1407114118
],
14072-
"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."
14119+
"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."
1407314120
},
1407414121
"clear": {
1407514122
"anyOf": [
@@ -14156,9 +14203,12 @@
1415614203
"$ref": "#/definitions/Binding"
1415714204
},
1415814205
"type": "object"
14206+
},
14207+
{
14208+
"$ref": "#/definitions/LegendBinding"
1415914209
}
1416014210
],
14161-
"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."
14211+
"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."
1416214212
},
1416314213
"clear": {
1416414214
"anyOf": [

examples/compiled/interactive_legend.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
{
2+
"$schema": "https://vega.github.io/schema/vega/v5.json",
3+
"background": "white",
4+
"padding": 5,
5+
"width": 300,
6+
"height": 200,
7+
"style": "cell",
8+
"data": [
9+
{"name": "industry_store"},
10+
{
11+
"name": "source_0",
12+
"url": "data/unemployment-across-industries.json",
13+
"format": {"type": "json", "parse": {"date": "date"}},
14+
"transform": [
15+
{
16+
"type": "formula",
17+
"as": "yearmonth_date",
18+
"expr": "datetime(year(datum[\"date\"]), month(datum[\"date\"]), 1, 0, 0, 0, 0)"
19+
},
20+
{
21+
"type": "aggregate",
22+
"groupby": ["yearmonth_date", "series"],
23+
"ops": ["sum"],
24+
"fields": ["count"],
25+
"as": ["sum_count"]
26+
},
27+
{
28+
"type": "impute",
29+
"field": "sum_count",
30+
"groupby": ["series"],
31+
"key": "yearmonth_date",
32+
"method": "value",
33+
"value": 0
34+
},
35+
{
36+
"type": "stack",
37+
"groupby": ["yearmonth_date"],
38+
"field": "sum_count",
39+
"sort": {"field": ["series"], "order": ["descending"]},
40+
"as": ["sum_count_start", "sum_count_end"],
41+
"offset": "center"
42+
}
43+
]
44+
}
45+
],
46+
"signals": [
47+
{
48+
"name": "unit",
49+
"value": {},
50+
"on": [
51+
{"events": "mousemove", "update": "isTuple(group()) ? group() : unit"}
52+
]
53+
},
54+
{
55+
"name": "industry_series_legend",
56+
"value": null,
57+
"on": [
58+
{
59+
"events": [
60+
{
61+
"source": "view",
62+
"type": "click",
63+
"markname": "series_legend_symbols"
64+
},
65+
{
66+
"source": "view",
67+
"type": "click",
68+
"markname": "series_legend_labels"
69+
},
70+
{
71+
"source": "view",
72+
"type": "click",
73+
"markname": "series_legend_entries"
74+
}
75+
],
76+
"update": "datum.value || item().items[0].items[0].datum.value",
77+
"force": true
78+
},
79+
{
80+
"events": [{"source": "view", "type": "click"}],
81+
"update": "!event.item || !datum ? null : industry_series_legend",
82+
"force": true
83+
}
84+
]
85+
},
86+
{
87+
"name": "industry",
88+
"update": "vlSelectionResolve(\"industry_store\", \"union\", true)"
89+
},
90+
{
91+
"name": "industry_tuple",
92+
"update": "industry_series_legend !== null ? {fields: industry_tuple_fields, values: [industry_series_legend]} : null"
93+
},
94+
{
95+
"name": "industry_tuple_fields",
96+
"value": [{"type": "E", "field": "series"}]
97+
},
98+
{
99+
"name": "industry_toggle",
100+
"value": false,
101+
"on": [
102+
{
103+
"events": {"merge": [{"source": "view", "type": "click"}]},
104+
"update": "event.shiftKey"
105+
}
106+
]
107+
},
108+
{
109+
"name": "industry_modify",
110+
"update": "modify(\"industry_store\", industry_toggle ? null : industry_tuple, industry_toggle ? null : true, industry_toggle ? industry_tuple : null)"
111+
}
112+
],
113+
"marks": [
114+
{
115+
"name": "pathgroup",
116+
"type": "group",
117+
"from": {
118+
"facet": {
119+
"name": "faceted_path_main",
120+
"data": "source_0",
121+
"groupby": ["series"]
122+
}
123+
},
124+
"encode": {
125+
"update": {
126+
"width": {"field": {"group": "width"}},
127+
"height": {"field": {"group": "height"}}
128+
}
129+
},
130+
"marks": [
131+
{
132+
"name": "marks",
133+
"type": "area",
134+
"style": ["area"],
135+
"sort": {"field": "datum[\"yearmonth_date\"]"},
136+
"interactive": true,
137+
"from": {"data": "faceted_path_main"},
138+
"encode": {
139+
"update": {
140+
"orient": {"value": "vertical"},
141+
"fill": {"scale": "color", "field": "series"},
142+
"opacity": [
143+
{
144+
"test": "!(length(data(\"industry_store\"))) || (vlSelectionTest(\"industry_store\", datum))",
145+
"value": 1
146+
},
147+
{"value": 0.2}
148+
],
149+
"x": {"scale": "x", "field": "yearmonth_date"},
150+
"y": {"scale": "y", "field": "sum_count_end"},
151+
"y2": {"scale": "y", "field": "sum_count_start"},
152+
"defined": {
153+
"signal": "isValid(datum[\"yearmonth_date\"]) && isFinite(+datum[\"yearmonth_date\"]) && isValid(datum[\"sum_count\"]) && isFinite(+datum[\"sum_count\"])"
154+
}
155+
}
156+
}
157+
}
158+
]
159+
}
160+
],
161+
"scales": [
162+
{
163+
"name": "x",
164+
"type": "time",
165+
"domain": {"data": "source_0", "field": "yearmonth_date"},
166+
"range": [0, {"signal": "width"}]
167+
},
168+
{
169+
"name": "y",
170+
"type": "linear",
171+
"domain": {
172+
"data": "source_0",
173+
"fields": ["sum_count_start", "sum_count_end"]
174+
},
175+
"range": [{"signal": "height"}, 0],
176+
"nice": true,
177+
"zero": true
178+
},
179+
{
180+
"name": "color",
181+
"type": "ordinal",
182+
"domain": {"data": "source_0", "field": "series", "sort": true},
183+
"range": {"scheme": "category20b"}
184+
}
185+
],
186+
"axes": [
187+
{
188+
"scale": "x",
189+
"orient": "bottom",
190+
"gridScale": "y",
191+
"grid": true,
192+
"tickCount": {"signal": "ceil(width/40)"},
193+
"domain": false,
194+
"labels": false,
195+
"maxExtent": 0,
196+
"minExtent": 0,
197+
"ticks": false,
198+
"zindex": 0
199+
},
200+
{
201+
"scale": "x",
202+
"orient": "bottom",
203+
"grid": false,
204+
"title": "date (year-month)",
205+
"domain": false,
206+
"tickSize": 0,
207+
"labelFlush": true,
208+
"labelOverlap": true,
209+
"tickCount": {"signal": "ceil(width/40)"},
210+
"encode": {
211+
"labels": {
212+
"update": {"text": {"signal": "timeFormat(datum.value, '%Y')"}}
213+
}
214+
},
215+
"zindex": 0
216+
}
217+
],
218+
"legends": [
219+
{
220+
"fill": "color",
221+
"gradientLength": {"signal": "clamp(height, 64, 200)"},
222+
"symbolType": "circle",
223+
"title": "series",
224+
"encode": {
225+
"labels": {
226+
"name": "series_legend_labels",
227+
"interactive": true,
228+
"update": {
229+
"opacity": [
230+
{
231+
"test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))",
232+
"value": 1
233+
},
234+
{"value": 0.35}
235+
]
236+
}
237+
},
238+
"symbols": {
239+
"name": "series_legend_symbols",
240+
"interactive": true,
241+
"update": {
242+
"opacity": [
243+
{
244+
"test": "(!length(data(\"industry_store\")) || (industry[\"series\"] && indexof(industry[\"series\"], datum.value) >= 0))",
245+
"value": 1
246+
},
247+
{"value": 0.35}
248+
]
249+
}
250+
},
251+
"entries": {
252+
"name": "series_legend_entries",
253+
"interactive": true,
254+
"update": {"fill": {"value": "transparent"}}
255+
}
256+
}
257+
}
258+
]
259+
}

examples/compiled/interactive_legend_dblclick.svg

Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)