Skip to content

Commit

Permalink
feat: interactive legends (#5305)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
arvind and domoritz authored Nov 19, 2019
1 parent 4d1fc2d commit 89108f0
Show file tree
Hide file tree
Showing 44 changed files with 1,412 additions and 64 deletions.
54 changes: 52 additions & 2 deletions build/vega-lite-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9449,6 +9449,19 @@
},
"type": "object"
},
"LegendBinding": {
"anyOf": [
{
"enum": [
"legend"
],
"type": "string"
},
{
"$ref": "#/definitions/LegendStreamBinding"
}
]
},
"LegendConfig": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions examples/compiled/interactive_legend.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
259 changes: 259 additions & 0 deletions examples/compiled/interactive_legend.vg.json
Original file line number Diff line number Diff line change
@@ -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"}}
}
}
}
]
}
1 change: 1 addition & 0 deletions examples/compiled/interactive_legend_dblclick.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 89108f0

Please sign in to comment.