From 03b948ecb48d4dbddaece931bf3e77f901dbaae1 Mon Sep 17 00:00:00 2001 From: Arvind Satyanarayan Date: Mon, 25 Sep 2017 18:05:34 -0700 Subject: [PATCH] Expose an "empty" property for selections. By default, all data values are considered to lie within an empty selection. When set to none, empty selections contain no data values. --- build/vega-lite-schema.json | 56 +++++ ...h_simple.svg => paintbrush_simple_all.svg} | 0 ....vg.json => paintbrush_simple_all.vg.json} | 0 examples/compiled/paintbrush_simple_none.svg | 1 + .../compiled/paintbrush_simple_none.vg.json | 221 ++++++++++++++++++ ....vl.json => paintbrush_simple_all.vl.json} | 5 +- examples/specs/paintbrush_simple_none.vl.json | 21 ++ site/docs/selection/selection.md | 9 +- src/compile/selection/selection.ts | 12 +- src/selection.ts | 21 +- test/compile/selection/predicate.test.ts | 10 +- 11 files changed, 346 insertions(+), 10 deletions(-) rename examples/compiled/{paintbrush_simple.svg => paintbrush_simple_all.svg} (100%) rename examples/compiled/{paintbrush_simple.vg.json => paintbrush_simple_all.vg.json} (100%) create mode 100644 examples/compiled/paintbrush_simple_none.svg create mode 100644 examples/compiled/paintbrush_simple_none.vg.json rename examples/specs/{paintbrush_simple.vl.json => paintbrush_simple_all.vl.json} (81%) create mode 100644 examples/specs/paintbrush_simple_none.vl.json diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 568bdbbf64..4c5dbc65cf 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -659,6 +659,14 @@ "BaseSelectionDef": { "additionalProperties": false, "properties": { + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -2714,6 +2722,14 @@ ], "type": "string" }, + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -2776,6 +2792,14 @@ ], "type": "string" }, + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -3680,6 +3704,14 @@ "MultiSelection": { "additionalProperties": false, "properties": { + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -3728,6 +3760,14 @@ "MultiSelectionConfig": { "additionalProperties": false, "properties": { + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -4706,6 +4746,14 @@ ], "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\nSee the [bind transform](bind.html) documentation for more information." }, + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { @@ -4761,6 +4809,14 @@ ], "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\nSee the [bind transform](bind.html) documentation for more information." }, + "empty": { + "description": "By default, all data values are considered to lie within an empty selection.\nWhen set to `none`, empty selections contain no data values.", + "enum": [ + "all", + "none" + ], + "type": "string" + }, "encodings": { "description": "An array of encoding channels. The corresponding data field values\nmust match for a data tuple to fall within the selection.", "items": { diff --git a/examples/compiled/paintbrush_simple.svg b/examples/compiled/paintbrush_simple_all.svg similarity index 100% rename from examples/compiled/paintbrush_simple.svg rename to examples/compiled/paintbrush_simple_all.svg diff --git a/examples/compiled/paintbrush_simple.vg.json b/examples/compiled/paintbrush_simple_all.vg.json similarity index 100% rename from examples/compiled/paintbrush_simple.vg.json rename to examples/compiled/paintbrush_simple_all.vg.json diff --git a/examples/compiled/paintbrush_simple_none.svg b/examples/compiled/paintbrush_simple_none.svg new file mode 100644 index 0000000000..b410e2856d --- /dev/null +++ b/examples/compiled/paintbrush_simple_none.svg @@ -0,0 +1 @@ +050100150200Horsepower01020304050Miles_per_Gallon \ No newline at end of file diff --git a/examples/compiled/paintbrush_simple_none.vg.json b/examples/compiled/paintbrush_simple_none.vg.json new file mode 100644 index 0000000000..e4b49ad63f --- /dev/null +++ b/examples/compiled/paintbrush_simple_none.vg.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v3.0.json", + "autosize": "pad", + "padding": 5, + "width": 200, + "height": 200, + "style": "cell", + "data": [ + { + "name": "paintbrush_store" + }, + { + "name": "source_0", + "url": "data/cars.json", + "format": { + "type": "json", + "parse": { + "Horsepower": "number", + "Miles_per_Gallon": "number" + } + }, + "transform": [ + { + "type": "identifier", + "as": "_vgsid_" + }, + { + "type": "filter", + "expr": "datum[\"Horsepower\"] !== null && !isNaN(datum[\"Horsepower\"]) && datum[\"Miles_per_Gallon\"] !== null && !isNaN(datum[\"Miles_per_Gallon\"])" + } + ] + } + ], + "signals": [ + { + "name": "unit", + "value": {}, + "on": [ + { + "events": "mousemove", + "update": "isTuple(group()) ? group() : unit" + } + ] + }, + { + "name": "paintbrush_tuple", + "value": {}, + "on": [ + { + "events": [ + { + "source": "scope", + "type": "mouseover" + } + ], + "update": "datum && item().mark.marktype !== 'group' ? {unit: \"\", encodings: [], fields: [\"_vgsid_\"], values: [datum[\"_vgsid_\"]]} : null", + "force": true + } + ] + }, + { + "name": "paintbrush_toggle", + "value": false, + "on": [ + { + "events": [ + { + "source": "scope", + "type": "mouseover" + } + ], + "update": "event.shiftKey" + } + ] + }, + { + "name": "paintbrush_modify", + "on": [ + { + "events": { + "signal": "paintbrush_tuple" + }, + "update": "modify(\"paintbrush_store\", paintbrush_toggle ? null : paintbrush_tuple, paintbrush_toggle ? null : true, paintbrush_toggle ? paintbrush_tuple : null)" + } + ] + } + ], + "marks": [ + { + "name": "marks", + "type": "symbol", + "style": [ + "point" + ], + "from": { + "data": "source_0" + }, + "encode": { + "update": { + "x": { + "scale": "x", + "field": "Horsepower" + }, + "y": { + "scale": "y", + "field": "Miles_per_Gallon" + }, + "stroke": { + "value": "#4c78a8" + }, + "fill": { + "value": "transparent" + }, + "size": [ + { + "test": "(vlMulti(\"paintbrush_store\", datum))", + "value": 300 + }, + { + "value": 50 + } + ], + "opacity": { + "value": 0.7 + } + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "linear", + "domain": { + "data": "source_0", + "field": "Horsepower" + }, + "range": [ + 0, + { + "signal": "width" + } + ], + "nice": true, + "zero": true + }, + { + "name": "y", + "type": "linear", + "domain": { + "data": "source_0", + "field": "Miles_per_Gallon" + }, + "range": [ + { + "signal": "height" + }, + 0 + ], + "nice": true, + "zero": true + } + ], + "axes": [ + { + "scale": "x", + "labelOverlap": true, + "orient": "bottom", + "tickCount": { + "signal": "ceil(width/40)" + }, + "title": "Horsepower", + "zindex": 1 + }, + { + "scale": "x", + "domain": false, + "grid": true, + "labels": false, + "maxExtent": 0, + "minExtent": 0, + "orient": "bottom", + "tickCount": { + "signal": "ceil(width/40)" + }, + "ticks": false, + "zindex": 0, + "gridScale": "y" + }, + { + "scale": "y", + "labelOverlap": true, + "orient": "left", + "tickCount": { + "signal": "ceil(height/40)" + }, + "title": "Miles_per_Gallon", + "zindex": 1 + }, + { + "scale": "y", + "domain": false, + "grid": true, + "labels": false, + "maxExtent": 0, + "minExtent": 0, + "orient": "left", + "tickCount": { + "signal": "ceil(height/40)" + }, + "ticks": false, + "zindex": 0, + "gridScale": "x" + } + ], + "config": { + "axisY": { + "minExtent": 30 + } + } +} diff --git a/examples/specs/paintbrush_simple.vl.json b/examples/specs/paintbrush_simple_all.vl.json similarity index 81% rename from examples/specs/paintbrush_simple.vl.json rename to examples/specs/paintbrush_simple_all.vl.json index 2b38b3ec48..07abab2ab4 100644 --- a/examples/specs/paintbrush_simple.vl.json +++ b/examples/specs/paintbrush_simple_all.vl.json @@ -2,7 +2,10 @@ "$schema": "https://vega.github.io/schema/vega-lite/v2.json", "data": {"url": "data/cars.json"}, "selection": { - "paintbrush": {"type": "multi", "on": "mouseover"} + "paintbrush": { + "type": "multi", + "on": "mouseover", "empty": "all" + } }, "mark": "point", "encoding": { diff --git a/examples/specs/paintbrush_simple_none.vl.json b/examples/specs/paintbrush_simple_none.vl.json new file mode 100644 index 0000000000..916552856b --- /dev/null +++ b/examples/specs/paintbrush_simple_none.vl.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v2.json", + "data": {"url": "data/cars.json"}, + "selection": { + "paintbrush": { + "type": "multi", + "on": "mouseover", "empty": "none" + } + }, + "mark": "point", + "encoding": { + "x": {"field": "Horsepower", "type": "quantitative"}, + "y": {"field": "Miles_per_Gallon", "type": "quantitative"}, + "size": { + "condition": { + "selection": "paintbrush", "value": 300 + }, + "value": 50 + } + } +} diff --git a/site/docs/selection/selection.md b/site/docs/selection/selection.md index b9a9212666..e9635b68ae 100644 --- a/site/docs/selection/selection.md +++ b/site/docs/selection/selection.md @@ -48,7 +48,7 @@ For example, try the different types against the example selection (named `pts`) {:#selection-on} While selection types provide useful defaults, it can often be useful to override these properties to customize the interaction design. The following properties are available to do so: -{% include table.html props="on,resolve,mark" source="IntervalSelection" %} +{% include table.html props="on,empty,resolve,mark" source="IntervalSelection" %} For instance, with the `on` property, a single rectangle in the heatmap below can now be selected on double-click instead. @@ -91,9 +91,12 @@ Vega-Lite provides a number of selection _transformations_ to further customize Selections can be used to conditionally specify visual encodings -- encode data values one way if they fall within the selection, and another if they do not. For instance, in the first two examples on this page, rectangles are colored based on whether or not their data values fall within the `pts` selection. If they do, they are colored by the number of records; and, if they do not, they are left grey. -In this example, a selection (named `paintbrush`) is used to resize the points in the scatterplot on hover. +In this example, a selection (named `paintbrush`) is used to resize the points in the scatterplot on hover. This example is also useful for understanding the difference when empty selections are set to contain of the data values. -
+
See the [`condition`](condition.html) documentation for more information. diff --git a/src/compile/selection/selection.ts b/src/compile/selection/selection.ts index d965538bb1..acd460f695 100644 --- a/src/compile/selection/selection.ts +++ b/src/compile/selection/selection.ts @@ -30,6 +30,7 @@ export interface SelectionComponent { // predicate?: string; bind?: 'scales' | VgBinding | {[key: string]: VgBinding}; resolve: SelectionResolution; + empty: 'all' | 'none'; mark?: BrushConfig; // Transforms @@ -228,14 +229,19 @@ export function predicate(model: Model, selections: LogicalOperand, dfno } } - stores.push(store); + if (selCmpt.empty !== 'none') { + stores.push(store); + } + return compiler(selCmpt.type).predicate + `(${store}, datum` + (selCmpt.resolve === 'global' ? ')' : `, ${stringValue(selCmpt.resolve)})`); } const predicateStr = logicalExpr(selections, expr); - return '!(' + stores.map((s) => `length(data(${s}))`).join(' || ') + - `) || (${predicateStr})`; + return (stores.length + ? '!(' + stores.map((s) => `length(data(${s}))`).join(' || ') + ') || ' + : '' + ) + `(${predicateStr})`; } // Selections are parsed _after_ scales. If a scale domain is set to diff --git a/src/selection.ts b/src/selection.ts index f031ec50c2..9d74cee307 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -36,6 +36,12 @@ export interface BaseSelectionDef { * fall within the selection. */ fields?: string[]; + + /** + * By default, all data values are considered to lie within an empty selection. + * When set to `none`, empty selections contain no data values. + */ + empty?: 'all' | 'none'; } export interface SingleSelectionConfig extends BaseSelectionDef { @@ -199,8 +205,19 @@ export interface SelectionConfig { } export const defaultConfig:SelectionConfig = { - single: {on: 'click', fields: [SELECTION_ID], resolve: 'global'}, - multi: {on: 'click', fields: [SELECTION_ID], toggle: 'event.shiftKey', resolve: 'global'}, + single: { + on: 'click', + fields: [SELECTION_ID], + resolve: 'global', + empty: 'all' + }, + multi: { + on: 'click', + fields: [SELECTION_ID], + toggle: 'event.shiftKey', + resolve: 'global', + empty: 'all' + }, interval: { on: '[mousedown, window:mouseup] > window:mousemove!', encodings: ['x', 'y'], diff --git a/test/compile/selection/predicate.test.ts b/test/compile/selection/predicate.test.ts index 12b1ac8d19..93271e8401 100644 --- a/test/compile/selection/predicate.test.ts +++ b/test/compile/selection/predicate.test.ts @@ -37,13 +37,16 @@ describe('Selection Predicate', function() { model.component.selection = selection.parseUnitSelection(model, { "one": {"type": "single"}, "two": {"type": "multi", "resolve": "union"}, - "thr-ee": {"type": "interval", "resolve": "intersect"} + "thr-ee": {"type": "interval", "resolve": "intersect"}, + "four": {"type": "single", "empty": "none"} }); it('generates the predicate expression', function() { assert.equal(predicate(model, "one"), '!(length(data("one_store"))) || (vlSingle("one_store", datum))'); + assert.equal(predicate(model, "four"), '(vlSingle("four_store", datum))'); + assert.equal(predicate(model, {"not": "one"}), '!(length(data("one_store"))) || (!(vlSingle("one_store", datum)))'); @@ -52,6 +55,11 @@ describe('Selection Predicate', function() { '(!((vlSingle("one_store", datum)) && ' + '(vlMulti("two_store", datum, "union"))))'); + assert.equal(predicate(model, {"not": {"and": ["one", "four"]}}), + '!(length(data("one_store"))) || ' + + '(!((vlSingle("one_store", datum)) && ' + + '(vlSingle("four_store", datum))))'); + assert.equal(predicate(model, {"and": ["one", "two", {"not": "thr-ee"}]}), '!(length(data("one_store")) || length(data("two_store")) || length(data("thr_ee_store"))) || ' + '((vlSingle("one_store", datum)) && ' +