diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 0e99779025..a936969728 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -20729,6 +20729,106 @@ }, "type": "object" }, + "RegionSelectionConfig": { + "additionalProperties": false, + "properties": { + "clear": { + "anyOf": [ + { + "$ref": "#/definitions/Stream" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ], + "description": "Clears the selection, emptying it of all values. This property can be a [Event Stream](https://vega.github.io/vega/docs/event-streams/) or `false` to disable clear.\n\n__Default value:__ `dblclick`.\n\n__See also:__ [`clear` examples ](https://vega.github.io/vega-lite/docs/selection.html#clear) in the documentation." + }, + "encodings": { + "description": "An array of encoding channels. The corresponding data field values must match for a data tuple to fall within the selection.\n\n__See also:__ The [projection with `encodings` and `fields` section](https://vega.github.io/vega-lite/docs/selection.html#project) in the documentation.", + "items": { + "$ref": "#/definitions/SingleDefUnitChannel" + }, + "type": "array" + }, + "mark": { + "$ref": "#/definitions/BrushConfig", + "description": "A region selection also adds a path mark to depict the shape of the region. The `mark` property can be used to customize the appearance of the mark.\n\n__See also:__ [`mark` examples](https://vega.github.io/vega-lite/docs/selection.html#mark) in the documentation." + }, + "on": { + "anyOf": [ + { + "$ref": "#/definitions/Stream" + }, + { + "type": "string" + } + ], + "description": "A [Vega event stream](https://vega.github.io/vega/docs/event-streams/) (object or selector) that triggers the selection. For interval selections, the event stream must specify a [start and end](https://vega.github.io/vega/docs/event-streams/#between-filters).\n\n__See also:__ [`on` examples](https://vega.github.io/vega-lite/docs/selection.html#on) in the documentation." + }, + "resolve": { + "$ref": "#/definitions/SelectionResolution", + "description": "With layered and multi-view displays, a strategy that determines how selections' data queries are resolved when applied in a filter transform, conditional encoding rule, or scale domain.\n\nOne of:\n- `\"global\"` -- only one brush exists for the entire SPLOM. When the user begins to drag, any previous brushes are cleared, and a new one is constructed.\n- `\"union\"` -- each cell contains its own brush, and points are highlighted if they lie within _any_ of these individual brushes.\n- `\"intersect\"` -- each cell contains its own brush, and points are highlighted only if they fall within _all_ of these individual brushes.\n\n__Default value:__ `global`.\n\n__See also:__ [`resolve` examples](https://vega.github.io/vega-lite/docs/selection.html#resolve) in the documentation." + }, + "type": { + "const": "region", + "description": "Determines the default event processing and data query for the selection. Vega-Lite currently supports two selection types:\n\n- `\"point\"` -- to select multiple discrete data values; the first value is selected on `click` and additional values toggled on shift-click.\n- `\"interval\"` -- to select a continuous range of data values on `drag`.", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "RegionSelectionConfigWithoutType": { + "additionalProperties": false, + "properties": { + "clear": { + "anyOf": [ + { + "$ref": "#/definitions/Stream" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ], + "description": "Clears the selection, emptying it of all values. This property can be a [Event Stream](https://vega.github.io/vega/docs/event-streams/) or `false` to disable clear.\n\n__Default value:__ `dblclick`.\n\n__See also:__ [`clear` examples ](https://vega.github.io/vega-lite/docs/selection.html#clear) in the documentation." + }, + "encodings": { + "description": "An array of encoding channels. The corresponding data field values must match for a data tuple to fall within the selection.\n\n__See also:__ The [projection with `encodings` and `fields` section](https://vega.github.io/vega-lite/docs/selection.html#project) in the documentation.", + "items": { + "$ref": "#/definitions/SingleDefUnitChannel" + }, + "type": "array" + }, + "mark": { + "$ref": "#/definitions/BrushConfig", + "description": "A region selection also adds a path mark to depict the shape of the region. The `mark` property can be used to customize the appearance of the mark.\n\n__See also:__ [`mark` examples](https://vega.github.io/vega-lite/docs/selection.html#mark) in the documentation." + }, + "on": { + "anyOf": [ + { + "$ref": "#/definitions/Stream" + }, + { + "type": "string" + } + ], + "description": "A [Vega event stream](https://vega.github.io/vega/docs/event-streams/) (object or selector) that triggers the selection. For interval selections, the event stream must specify a [start and end](https://vega.github.io/vega/docs/event-streams/#between-filters).\n\n__See also:__ [`on` examples](https://vega.github.io/vega-lite/docs/selection.html#on) in the documentation." + }, + "resolve": { + "$ref": "#/definitions/SelectionResolution", + "description": "With layered and multi-view displays, a strategy that determines how selections' data queries are resolved when applied in a filter transform, conditional encoding rule, or scale domain.\n\nOne of:\n- `\"global\"` -- only one brush exists for the entire SPLOM. When the user begins to drag, any previous brushes are cleared, and a new one is constructed.\n- `\"union\"` -- each cell contains its own brush, and points are highlighted if they lie within _any_ of these individual brushes.\n- `\"intersect\"` -- each cell contains its own brush, and points are highlighted only if they fall within _all_ of these individual brushes.\n\n__Default value:__ `global`.\n\n__See also:__ [`resolve` examples](https://vega.github.io/vega-lite/docs/selection.html#resolve) in the documentation." + } + }, + "type": "object" + }, "RegressionTransform": { "additionalProperties": false, "properties": { @@ -21943,6 +22043,10 @@ "point": { "$ref": "#/definitions/PointSelectionConfigWithoutType", "description": "The default definition for a [`point`](https://vega.github.io/vega-lite/docs/parameter.html#select) selection. All properties and transformations for a point selection definition (except `type`) may be specified here.\n\nFor instance, setting `point` to `{\"on\": \"dblclick\"}` populates point selections on double-click by default." + }, + "region": { + "$ref": "#/definitions/RegionSelectionConfigWithoutType", + "description": "The default definition for an [`region`](https://vega.github.io/vega-lite/docs/parameter.html#select) selection. All properties and transformations for an region selection definition (except `type`) may be specified here." } }, "type": "object" @@ -22017,6 +22121,9 @@ }, { "$ref": "#/definitions/IntervalSelectionConfig" + }, + { + "$ref": "#/definitions/RegionSelectionConfig" } ], "description": "Determines the default event processing and data query for the selection. Vega-Lite currently supports two selection types:\n\n- `\"point\"` -- to select multiple discrete data values; the first value is selected on `click` and additional values toggled on shift-click.\n- `\"interval\"` -- to select a continuous range of data values on `drag`." @@ -22056,7 +22163,8 @@ "SelectionType": { "enum": [ "point", - "interval" + "interval", + "region" ], "type": "string" }, @@ -29516,6 +29624,9 @@ }, { "$ref": "#/definitions/IntervalSelectionConfig" + }, + { + "$ref": "#/definitions/RegionSelectionConfig" } ], "description": "Determines the default event processing and data query for the selection. Vega-Lite currently supports two selection types:\n\n- `\"point\"` -- to select multiple discrete data values; the first value is selected on `click` and additional values toggled on shift-click.\n- `\"interval\"` -- to select a continuous range of data values on `drag`." diff --git a/examples/compiled/selection_type_region.png b/examples/compiled/selection_type_region.png new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/compiled/selection_type_region.svg b/examples/compiled/selection_type_region.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/compiled/selection_type_region.vg.json b/examples/compiled/selection_type_region.vg.json new file mode 100644 index 0000000000..8c8188605c --- /dev/null +++ b/examples/compiled/selection_type_region.vg.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 200, + "height": 200, + "style": "cell", + "data": [ + {"name": "brush_store"}, + { + "name": "source_0", + "url": "data/cars.json", + "format": {"type": "json"}, + "transform": [ + { + "type": "filter", + "expr": "isValid(datum[\"Horsepower\"]) && isFinite(+datum[\"Horsepower\"]) && isValid(datum[\"Miles_per_Gallon\"]) && isFinite(+datum[\"Miles_per_Gallon\"])" + } + ] + } + ], + "signals": [ + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "brush", + "update": "vlSelectionResolve(\"brush_store\", \"union\")" + }, + { + "name": "brush_tuple", + "on": [ + { + "events": [{"signal": "brush_screen_path"}], + "update": "vlSelectionTuples(intersectLasso(\"marks\", brush_screen_path, unit), {unit: \"\"})" + }, + {"events": [{"source": "view", "type": "dblclick"}], "update": "null"} + ] + }, + { + "name": "brush_screen_path", + "init": "[]", + "on": [ + { + "events": {"source": "scope", "type": "mousedown"}, + "update": "[[x(unit), y(unit)]]" + }, + { + "events": { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + {"source": "scope", "type": "mousedown"}, + {"source": "window", "type": "mouseup"} + ] + }, + "update": "lassoAppend(brush_screen_path, clamp(x(unit), 0, width), clamp(y(unit), 0, height))" + } + ] + }, + {"name": "brush_tuple_fields", "value": []}, + { + "name": "brush_modify", + "on": [ + { + "events": {"signal": "brush_tuple"}, + "update": "modify(\"brush_store\", brush_tuple, true)" + } + ] + } + ], + "marks": [ + { + "name": "brush_brush", + "type": "path", + "encode": { + "enter": { + "fill": {"value": "#333"}, + "fillOpacity": {"value": 0.125}, + "stroke": {"value": "gray"}, + "strokeWidth": {"value": 2}, + "strokeDash": {"value": [8, 5]} + }, + "update": {"path": {"signal": "lassoPath(brush_screen_path)"}} + } + }, + { + "name": "marks", + "type": "symbol", + "style": ["point"], + "interactive": true, + "from": {"data": "source_0"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": {"value": "transparent"}, + "stroke": [ + { + "test": "!length(data(\"brush_store\")) || vlSelectionTest(\"brush_store\", datum)", + "scale": "color", + "field": "Cylinders" + }, + {"value": "grey"} + ], + "ariaRoleDescription": {"value": "point"}, + "description": { + "signal": "\"Horsepower: \" + (format(datum[\"Horsepower\"], \"\")) + \"; Miles_per_Gallon: \" + (format(datum[\"Miles_per_Gallon\"], \"\")) + \"; Cylinders: \" + (isValid(datum[\"Cylinders\"]) ? datum[\"Cylinders\"] : \"\"+datum[\"Cylinders\"])" + }, + "x": {"scale": "x", "field": "Horsepower"}, + "y": {"scale": "y", "field": "Miles_per_Gallon"} + } + } + } + ], + "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 + }, + { + "name": "color", + "type": "ordinal", + "domain": {"data": "source_0", "field": "Cylinders", "sort": true}, + "range": "ordinal", + "interpolate": "hcl" + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "gridScale": "x", + "grid": true, + "tickCount": {"signal": "ceil(height/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "Horsepower", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "title": "Miles_per_Gallon", + "labelOverlap": true, + "tickCount": {"signal": "ceil(height/40)"}, + "zindex": 0 + } + ], + "legends": [ + { + "stroke": "color", + "symbolType": "circle", + "title": "Cylinders", + "encode": { + "symbols": { + "update": { + "fill": {"value": "transparent"}, + "opacity": {"value": 0.7} + } + } + } + } + ] +} diff --git a/examples/compiled/selection_type_region_concat.png b/examples/compiled/selection_type_region_concat.png new file mode 100644 index 0000000000..aee9b59f58 Binary files /dev/null and b/examples/compiled/selection_type_region_concat.png differ diff --git a/examples/compiled/selection_type_region_concat.svg b/examples/compiled/selection_type_region_concat.svg new file mode 100644 index 0000000000..da5d98415d --- /dev/null +++ b/examples/compiled/selection_type_region_concat.svg @@ -0,0 +1 @@ +050100150200Horsepower01020304050Miles_per_Gallon \ No newline at end of file diff --git a/examples/compiled/selection_type_region_concat.vg.json b/examples/compiled/selection_type_region_concat.vg.json new file mode 100644 index 0000000000..a52c2b7eb4 --- /dev/null +++ b/examples/compiled/selection_type_region_concat.vg.json @@ -0,0 +1,306 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "description": "Two vertically concatenated charts that show a histogram of precipitation in Seattle and the relationship between min and max temperature.", + "background": "white", + "padding": 5, + "width": 200, + "data": [ + {"name": "brush_store"}, + {"name": "source_0", "url": "data/cars.json", "format": {"type": "json"}}, + { + "name": "data_0", + "source": "source_0", + "transform": [ + { + "type": "filter", + "expr": "isValid(datum[\"Horsepower\"]) && isFinite(+datum[\"Horsepower\"]) && isValid(datum[\"Miles_per_Gallon\"]) && isFinite(+datum[\"Miles_per_Gallon\"])" + } + ] + }, + { + "name": "data_1", + "source": "source_0", + "transform": [ + {"type": "filter", "expr": "brush"}, + { + "type": "filter", + "expr": "isValid(datum[\"Acceleration\"]) && isFinite(+datum[\"Acceleration\"]) && isValid(datum[\"Displacement\"]) && isFinite(+datum[\"Displacement\"])" + } + ] + } + ], + "signals": [ + {"name": "childHeight", "value": 200}, + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "brush", + "update": "vlSelectionResolve(\"brush_store\", \"union\")" + } + ], + "layout": {"padding": 20, "columns": 1, "bounds": "full", "align": "each"}, + "marks": [ + { + "type": "group", + "name": "concat_0_group", + "style": "cell", + "encode": { + "update": { + "width": {"signal": "width"}, + "height": {"signal": "childHeight"} + } + }, + "signals": [ + { + "name": "brush_tuple", + "on": [ + { + "events": [{"signal": "brush_screen_path"}], + "update": "vlSelectionTuples(intersectLasso(\"concat_0_marks\", brush_screen_path, unit), {unit: \"concat_0\"})" + }, + { + "events": [{"source": "view", "type": "dblclick"}], + "update": "null" + } + ] + }, + { + "name": "brush_screen_path", + "init": "[]", + "on": [ + { + "events": {"source": "scope", "type": "mousedown"}, + "update": "[[x(unit), y(unit)]]" + }, + { + "events": { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + {"source": "scope", "type": "mousedown"}, + {"source": "window", "type": "mouseup"} + ] + }, + "update": "lassoAppend(brush_screen_path, clamp(x(unit), 0, width), clamp(y(unit), 0, childHeight))" + } + ] + }, + {"name": "brush_tuple_fields", "value": []}, + { + "name": "brush_modify", + "on": [ + { + "events": {"signal": "brush_tuple"}, + "update": "modify(\"brush_store\", brush_tuple, true)" + } + ] + } + ], + "marks": [ + { + "name": "brush_brush", + "type": "path", + "encode": { + "enter": { + "fill": {"value": "#333"}, + "fillOpacity": {"value": 0.125}, + "stroke": {"value": "#d95f02"}, + "strokeWidth": {"value": 2}, + "strokeDash": {"value": [2, 8]} + }, + "update": {"path": {"signal": "lassoPath(brush_screen_path)"}} + } + }, + { + "name": "concat_0_marks", + "type": "symbol", + "style": ["point"], + "interactive": true, + "from": {"data": "data_0"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": {"value": "transparent"}, + "stroke": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "point"}, + "description": { + "signal": "\"Horsepower: \" + (format(datum[\"Horsepower\"], \"\")) + \"; Miles_per_Gallon: \" + (format(datum[\"Miles_per_Gallon\"], \"\"))" + }, + "x": {"scale": "concat_0_x", "field": "Horsepower"}, + "y": {"scale": "concat_0_y", "field": "Miles_per_Gallon"} + } + } + } + ], + "axes": [ + { + "scale": "concat_0_x", + "orient": "bottom", + "gridScale": "concat_0_y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "concat_0_y", + "orient": "left", + "gridScale": "concat_0_x", + "grid": true, + "tickCount": {"signal": "ceil(childHeight/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "concat_0_x", + "orient": "bottom", + "grid": false, + "title": "Horsepower", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "zindex": 0 + }, + { + "scale": "concat_0_y", + "orient": "left", + "grid": false, + "title": "Miles_per_Gallon", + "labelOverlap": true, + "tickCount": {"signal": "ceil(childHeight/40)"}, + "zindex": 0 + } + ] + }, + { + "type": "group", + "name": "concat_1_group", + "style": "cell", + "encode": { + "update": { + "width": {"signal": "width"}, + "height": {"signal": "childHeight"} + } + }, + "marks": [ + { + "name": "concat_1_marks", + "type": "symbol", + "style": ["point"], + "interactive": false, + "from": {"data": "data_1"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": {"value": "transparent"}, + "stroke": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "point"}, + "description": { + "signal": "\"Acceleration: \" + (format(datum[\"Acceleration\"], \"\")) + \"; Displacement: \" + (format(datum[\"Displacement\"], \"\"))" + }, + "x": {"scale": "concat_1_x", "field": "Acceleration"}, + "y": {"scale": "concat_1_y", "field": "Displacement"} + } + } + } + ], + "axes": [ + { + "scale": "concat_1_x", + "orient": "bottom", + "gridScale": "concat_1_y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "concat_1_y", + "orient": "left", + "gridScale": "concat_1_x", + "grid": true, + "tickCount": {"signal": "ceil(childHeight/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "concat_1_x", + "orient": "bottom", + "grid": false, + "title": "Acceleration", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "zindex": 0 + }, + { + "scale": "concat_1_y", + "orient": "left", + "grid": false, + "title": "Displacement", + "labelOverlap": true, + "tickCount": {"signal": "ceil(childHeight/40)"}, + "zindex": 0 + } + ] + } + ], + "scales": [ + { + "name": "concat_0_x", + "type": "linear", + "domain": {"data": "data_0", "field": "Horsepower"}, + "range": [0, {"signal": "width"}], + "nice": true, + "zero": true + }, + { + "name": "concat_0_y", + "type": "linear", + "domain": {"data": "data_0", "field": "Miles_per_Gallon"}, + "range": [{"signal": "childHeight"}, 0], + "nice": true, + "zero": true + }, + { + "name": "concat_1_x", + "type": "linear", + "domain": [0, 25], + "range": [0, {"signal": "width"}], + "zero": true + }, + { + "name": "concat_1_y", + "type": "linear", + "domain": [0, 500], + "range": [{"signal": "childHeight"}, 0], + "zero": true + } + ] +} diff --git a/examples/specs/selection_type_region.vl.json b/examples/specs/selection_type_region.vl.json new file mode 100644 index 0000000000..2140a11a8f --- /dev/null +++ b/examples/specs/selection_type_region.vl.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": {"url": "data/cars.json"}, + "params": [ + { + "name": "brush", + "select": "region" + } + ], + "mark": "point", + "encoding": { + "x": {"field": "Horsepower", "type": "quantitative"}, + "y": {"field": "Miles_per_Gallon", "type": "quantitative"}, + "color": { + "condition": {"param": "brush", "field": "Cylinders", "type": "ordinal"}, + "value": "grey" + } + } +} diff --git a/examples/specs/selection_type_region_concat.vl.json b/examples/specs/selection_type_region_concat.vl.json new file mode 100644 index 0000000000..9c0084478f --- /dev/null +++ b/examples/specs/selection_type_region_concat.vl.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "description": "Two vertically concatenated charts that show a histogram of precipitation in Seattle and the relationship between min and max temperature.", + "data": {"url": "data/cars.json", "format": {"type": "json"}}, + "vconcat": [ + { + "mark": "point", + "params": [{ + "name": "brush", + "select": { + "type": "region", + "mark": { + "fill": "#333", + "stroke": "#d95f02", + "strokeDash": [2, 8], + "strokeWidth": 2 + } + } + }], + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative" + }, + "y": { + "field": "Miles_per_Gallon", + "type": "quantitative" + } + } + }, + { + "transform": [ + { + "filter": "brush" + } + ], + "mark": "point", + "encoding": { + "x": { + "field": "Acceleration", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 25 + ] + } + }, + "y": { + "field": "Displacement", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 500 + ] + } + } + } + } + ] +} diff --git a/site/_data/examples.json b/site/_data/examples.json index 760e1aff13..2afa962841 100644 --- a/site/_data/examples.json +++ b/site/_data/examples.json @@ -821,6 +821,16 @@ "name": "selection_translate_scatterplot_drag", "title": "Scatterplot Pan & Zoom" }, + { + "name": "selection_type_region", + "title": "Region (lasso) selection", + "description": "The plot below uses a region selection, which causes the chart to include an interactive brush." + }, + { + "name": "selection_type_region_concat", + "title": "Customized region (lasso) selection", + "description": "The concatenated plot below uses a region selection, which causes the chart to include an interactive brush. The appearance can be customized." + }, { "name": "interactive_query_widgets", "title": "Query Widgets" diff --git a/site/_includes/docs_toc.md b/site/_includes/docs_toc.md index a71657fc06..8902f79e9c 100644 --- a/site/_includes/docs_toc.md +++ b/site/_includes/docs_toc.md @@ -287,6 +287,7 @@ - [Nominal]({{site.baseurl}}/docs/type.html#nominal) - [GeoJSON]({{site.baseurl}}/docs/type.html#geojson) - [Value]({{site.baseurl}}/docs/value.html) + - [Examples]({{site.baseurl}}/docs/value.html#examples) - [Projection]({{site.baseurl}}/docs/projection.html) - [Documentation Overview]({{site.baseurl}}/docs/projection.html#documentation-overview) - [Projection Properties]({{site.baseurl}}/docs/projection.html#projection-properties) @@ -329,6 +330,7 @@ - [Using Parameters]({{site.baseurl}}/docs/parameter.html#using-parameters) - [Selection Configuration]({{site.baseurl}}/docs/parameter.html#config) - [Value]({{site.baseurl}}/docs/value.html) + - [Examples]({{site.baseurl}}/docs/value.html#examples) - [Expr]({{site.baseurl}}/docs/parameter.html) - [Documentation Overview]({{site.baseurl}}/docs/parameter.html#documentation-overview) - [Defining a Parameter]({{site.baseurl}}/docs/parameter.html#defining-a-parameter) diff --git a/src/compile/selection/index.ts b/src/compile/selection/index.ts index 604e84a637..9628cd1b63 100644 --- a/src/compile/selection/index.ts +++ b/src/compile/selection/index.ts @@ -28,6 +28,7 @@ import toggle from './toggle'; import translate from './translate'; import zoom from './zoom'; import {ParameterName} from '../../parameter'; +import region from './region'; export const STORE = '_store'; export const TUPLE = '_tuple'; @@ -69,6 +70,7 @@ export interface SelectionCompiler { export const selectionCompilers: SelectionCompiler[] = [ point, interval, + region, project, toggle, diff --git a/src/compile/selection/region.ts b/src/compile/selection/region.ts new file mode 100644 index 0000000000..6617621b2f --- /dev/null +++ b/src/compile/selection/region.ts @@ -0,0 +1,90 @@ +import {OnEvent, Signal} from 'vega'; +import {stringValue} from 'vega-util'; +import {SelectionCompiler, TUPLE, unitName} from '.'; +import {warn} from '../../log'; +import {BRUSH} from './interval'; +import scales from './scales'; +export const SCREEN_PATH = '_screen_path'; + +const region: SelectionCompiler<'region'> = { + defined: selCmpt => selCmpt.type === 'region', + + signals: (model, selCmpt, signals) => { + const name = selCmpt.name; + const signalsToAdd: Signal[] = []; + + const screenPathName = `${name}${SCREEN_PATH}`; + + const w = model.getSizeSignalRef('width').signal; + const h = model.getSizeSignalRef('height').signal; + + signalsToAdd.push({ + name: `${name}${TUPLE}`, + on: [ + { + events: [{signal: screenPathName}], + update: `vlSelectionTuples(intersectLasso(${stringValue( + model.getName('marks') + )}, ${screenPathName}, unit), {unit: ${unitName(model)}})` + } + ] + }); + + const regionEvents = selCmpt.events.reduce((on, evt) => { + if (!evt.between) { + warn(`${evt} is not an ordered event stream for region selections.`); + return on; + } + + return [ + ...on, + {events: evt.between[0], update: `[[x(unit), y(unit)]]`}, + {events: evt, update: `lassoAppend(${screenPathName}, clamp(x(unit), 0, ${w}), clamp(y(unit), 0, ${h}))`} + ]; + }, [] as OnEvent[]); + + signalsToAdd.push({ + name: screenPathName, + init: '[]', + on: regionEvents + }); + + return [...signals, ...signalsToAdd]; + }, + + marks: (model, selCmpt, marks) => { + const name = selCmpt.name; + const {fill, fillOpacity, stroke, strokeDash, strokeWidth} = selCmpt.mark; + + const screenPathName = `${name}${SCREEN_PATH}`; + + // Do not add a brush if we're binding to scales. + if (scales.defined(selCmpt)) { + return marks; + } + + return [ + { + name: `${name + BRUSH}`, + type: 'path', + encode: { + enter: { + fill: {value: fill}, + fillOpacity: {value: fillOpacity}, + stroke: {value: stroke}, + strokeWidth: {value: strokeWidth}, + strokeDash: {value: strokeDash} + }, + update: { + path: { + signal: `lassoPath(${screenPathName})` + } + } + } + }, + ...marks + ]; + } +}; + +export default region; diff --git a/src/selection.ts b/src/selection.ts index fa7b1b6239..9331d563a5 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -7,15 +7,13 @@ import {ParameterName} from './parameter'; import {Dict} from './util'; export const SELECTION_ID = '_vgsid_'; -export type SelectionType = 'point' | 'interval'; +export type SelectionType = 'point' | 'interval' | 'region'; export type SelectionResolution = 'global' | 'union' | 'intersect'; export type SelectionInit = PrimitiveValue | DateTime; export type SelectionInitInterval = Vector2 | Vector2 | Vector2 | Vector2; - export type SelectionInitMapping = Dict; export type SelectionInitIntervalMapping = Dict; - export type LegendStreamBinding = {legend: string | Stream}; export type LegendBinding = 'legend' | LegendStreamBinding; @@ -200,6 +198,17 @@ export interface IntervalSelectionConfig extends BaseSelectionConfig<'interval'> mark?: BrushConfig; } +export interface RegionSelectionConfig extends BaseSelectionConfig<'region'> { + /** + * A region selection also adds a path mark to depict the + * shape of the region. The `mark` property can be used to customize the + * appearance of the mark. + * + * __See also:__ [`mark` examples](https://vega.github.io/vega-lite/docs/selection.html#mark) in the documentation. + */ + mark?: BrushConfig; +} + export interface SelectionParameter { /** * Required. A unique name for the selection parameter. Selection names should be valid JavaScript identifiers: they should contain only alphanumeric characters (or "$", or "_") and may not start with a digit. Reserved keywords that may not be used as parameter names are "datum", "event", "item", and "parent". @@ -212,7 +221,15 @@ export interface SelectionParameter { * - `"point"` -- to select multiple discrete data values; the first value is selected on `click` and additional values toggled on shift-click. * - `"interval"` -- to select a continuous range of data values on `drag`. */ - select: T | (T extends 'point' ? PointSelectionConfig : T extends 'interval' ? IntervalSelectionConfig : never); + select: + | T + | (T extends 'point' + ? PointSelectionConfig + : T extends 'interval' + ? IntervalSelectionConfig + : T extends 'region' + ? RegionSelectionConfig + : never); /** * Initialize the selection with a mapping between [projected channels or field names](https://vega.github.io/vega-lite/docs/selection.html#project) and initial values. @@ -282,6 +299,8 @@ export type PointSelectionConfigWithoutType = Omit export type IntervalSelectionConfigWithoutType = Omit; +export type RegionSelectionConfigWithoutType = Omit; + export interface SelectionConfig { /** * The default definition for a [`point`](https://vega.github.io/vega-lite/docs/parameter.html#select) selection. All properties and transformations @@ -299,6 +318,12 @@ export interface SelectionConfig { * interval selections by default. */ interval?: IntervalSelectionConfigWithoutType; + + /** + * The default definition for an [`region`](https://vega.github.io/vega-lite/docs/parameter.html#select) selection. All properties and transformations + * for an region selection definition (except `type`) may be specified here. + */ + region?: RegionSelectionConfigWithoutType; } export const defaultConfig: SelectionConfig = { @@ -317,6 +342,12 @@ export const defaultConfig: SelectionConfig = { mark: {fill: '#333', fillOpacity: 0.125, stroke: 'white'}, resolve: 'global', clear: 'dblclick' + }, + region: { + on: '[mousedown, window:mouseup] > window:mousemove!', + resolve: 'global', + mark: {fill: '#333', fillOpacity: 0.125, stroke: 'gray', strokeWidth: 2, strokeDash: [8, 5]}, + clear: 'dblclick' } }; diff --git a/test-runtime/index.html b/test-runtime/index.html index b3f511838c..dec8ddefee 100644 --- a/test-runtime/index.html +++ b/test-runtime/index.html @@ -41,6 +41,22 @@ ); } + function pureMouseEvt(type, target, opts) { + opts.bubbles = true; + target = winSrc.indexOf(type) < 0 ? target : window; + + target.dispatchEvent(type === 'wheel' ? new WheelEvent('wheel', opts) : new MouseEvent(type, opts)); + } + + function pureClear(id, parent, targetBrush) { + const el0 = mark(id, parent); + const [clientX, clientY] = coords(el0); + + pureMouseEvt('mousedown', el0, {clientX, clientY}); + pureMouseEvt('mouseup', window, {clientX, clientY}); + pureMouseEvt('click', el0, {clientX, clientY}); + } + function mark(id, parent) { return document.querySelector((parent ? `g.${parent} ` : '') + `g.mark-symbol.role-mark path:nth-child(${id})`); } @@ -50,6 +66,10 @@ return [Math.ceil(rect.left + rect.width / 2), Math.ceil(rect.top + rect.height / 2)]; } + function pointOnCircle(point, radius, angle) { + return {clientX: point.clientX + radius * Math.cos(angle), clientY: point.clientY + radius * Math.sin(angle)} + } + function brushOrEl(el, parent, _) { return !_ ? el : document.querySelector((parent ? `g.${parent} ` : '') + 'g.sel_brush > path'); } @@ -70,6 +90,44 @@ return (await view.runAsync()).data('sel_store'); } + async function polygonRegion(parent, targetBrush, id, shape) { + const el0 = mark(id, parent); + const [mdX, mdY] = coords(el0); + + shape.forEach((e, i) => { + const p = {clientX: mdX + e[0], clientY: mdY + e[1]} + if (i === 0) { + mouseEvt('mousedown', brushOrEl(el0, parent, targetBrush), p); + } else if (i === shape.length - 1) { + mouseEvt('mouseup', window, p); + } else { + pureMouseEvt('mousemove', brushOrEl(el0, parent, targetBrush), p); + } + }) + + return (await view.runAsync()).data('sel_store'); + } + + async function circleRegion(parent, targetBrush, id) { + const radius = 40; + const segments = 20; + + const el0 = mark(id, parent); + const [mdX, mdY] = coords(el0); + + for (let i = 0; i < segments; i++) { + if (i === 0) { + mouseEvt('mousedown', brushOrEl(el0, parent, targetBrush), pointOnCircle({clientX: mdX, clientY: mdY}, radius, 0)); + } else if (i === segments - 1) { + mouseEvt('mouseup', window, pointOnCircle({clientX: mdX, clientY: mdY}, radius, (i / (segments / 2)) * Math.PI)); + } else { + pureMouseEvt('mousemove', brushOrEl(el0, parent, targetBrush), pointOnCircle({clientX: mdX, clientY: mdY}, radius, (i / (segments / 2)) * Math.PI)); + } + } + + return (await view.runAsync()).data('sel_store'); + } + async function pt(id, parent, shiftKey) { const el = mark(id, parent); const [clientX, clientY] = coords(el); diff --git a/test-runtime/region.test.ts b/test-runtime/region.test.ts new file mode 100644 index 0000000000..26f4aef984 --- /dev/null +++ b/test-runtime/region.test.ts @@ -0,0 +1,114 @@ +import {TopLevelSpec} from '../src'; +import {SelectionType} from '../src/selection'; +import {clear, embedFn, region, regionByPolygon, spec, testRenderFn} from './util'; +import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; + +describe('interval selections at runtime in unit views', () => { + let page: Page; + let embed: (specification: TopLevelSpec) => Promise; + let testRender: (filename: string) => Promise; + + beforeAll(async () => { + page = await (global as any).__BROWSER__.newPage(); + embed = embedFn(page); + testRender = testRenderFn(page, `${type}/unit`); + await page.goto('http://0.0.0.0:8000/test-runtime/'); + }); + + afterAll(async () => { + await page.close(); + }); + + const type: SelectionType = 'region'; + + it('circle region test 1', async () => { + await embed(spec('unit', 0, {type})); + const store = await page.evaluate(region(14)); + + expect(store).toHaveLength(5); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + + await testRender(`circle_0`); + }); + + it('circle region test 2', async () => { + await embed(spec('unit', 0, {type})); + const store = await page.evaluate(region(3)); + + expect(store).toHaveLength(2); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + + await testRender(`cirlce_1`); + }); + + it('circle region test 3', async () => { + await embed(spec('unit', 0, {type})); + const store = await page.evaluate(region(6)); + + expect(store).toHaveLength(4); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + + await testRender(`circle_2`); + }); + + it('polygon region test 1', async () => { + await embed(spec('unit', 0, {type})); + + const store = await page.evaluate( + regionByPolygon(6, [ + [-30, -30], + [-30, 30], + [30, 30], + [30, -30] + ]) + ); + + expect(store).toHaveLength(4); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + + await testRender(`polygon_0`); + }); + + it('polygon region test 2', async () => { + await embed(spec('unit', 0, {type})); + + const l = 30; + const h = 15; + + const store = await page.evaluate( + regionByPolygon(14, [ + [-l, -l], + [-l, l], + [-h, h], + [-h, -h], + [h, -h], + [h, l], + [l, l], + [l, -l] + ]) + ); + + expect(store).toHaveLength(2); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + + await testRender(`polygon_1`); + }); + + it('should clear out stored extents', async () => { + await embed(spec('unit', 0, {type})); + let store = await page.evaluate(region(14)); + + expect(store).toHaveLength(5); + + store = await page.evaluate(clear(14)); + + expect(store).toBeUndefined(); + + await testRender(`clear_0`); + }); +}); diff --git a/test-runtime/resources/region/unit/circle_0.svg b/test-runtime/resources/region/unit/circle_0.svg new file mode 100644 index 0000000000..03cc7a7946 --- /dev/null +++ b/test-runtime/resources/region/unit/circle_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/unit/circle_2.svg b/test-runtime/resources/region/unit/circle_2.svg new file mode 100644 index 0000000000..8a80857997 --- /dev/null +++ b/test-runtime/resources/region/unit/circle_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/unit/cirlce_1.svg b/test-runtime/resources/region/unit/cirlce_1.svg new file mode 100644 index 0000000000..0d64826005 --- /dev/null +++ b/test-runtime/resources/region/unit/cirlce_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/unit/clear_0.svg b/test-runtime/resources/region/unit/clear_0.svg new file mode 100644 index 0000000000..a442a6aea6 --- /dev/null +++ b/test-runtime/resources/region/unit/clear_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/unit/polygon_0.svg b/test-runtime/resources/region/unit/polygon_0.svg new file mode 100644 index 0000000000..be79a5a71e --- /dev/null +++ b/test-runtime/resources/region/unit/polygon_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/unit/polygon_1.svg b/test-runtime/resources/region/unit/polygon_1.svg new file mode 100644 index 0000000000..de9472e38c --- /dev/null +++ b/test-runtime/resources/region/unit/polygon_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/util.ts b/test-runtime/util.ts index 192974f3a5..67e0dba66c 100644 --- a/test-runtime/util.ts +++ b/test-runtime/util.ts @@ -69,7 +69,6 @@ export const hits = { facet: [2, 6, 9], facet_clear: [3, 4, 8] }, - interval: { drag: [ [5, 14], @@ -198,6 +197,18 @@ export function parentSelector(compositeType: ComposeType, index: number) { return compositeType === 'facet' ? `cell > g:nth-child(${index + 1})` : `${UNIT_NAMES.repeat[index]}_group`; } +export function clear(id: number, parent?: string, targetBrush?: boolean) { + return `pureClear(${id}, ${stringValue(parent)}, ${!!targetBrush})`; +} + +export function region(id: number, parent?: string, targetBrush?: boolean) { + return `circleRegion(${stringValue(parent)}, ${!!targetBrush}, ${id})`; +} + +export function regionByPolygon(id: number, polygon: [number, number][], parent?: string, targetBrush?: boolean) { + return `polygonRegion(${stringValue(parent)}, ${!!targetBrush}, ${id}, ${JSON.stringify(polygon)})`; +} + export function brush(key: string, idx: number, parent?: string, targetBrush?: boolean) { const fn = key.match('_clear') ? 'clear' : 'brush'; return `${fn}(${hits.interval[key][idx].join(', ')}, ${stringValue(parent)}, ${!!targetBrush})`; diff --git a/test/compile/selection/region.test.ts b/test/compile/selection/region.test.ts new file mode 100644 index 0000000000..2ce39564e9 --- /dev/null +++ b/test/compile/selection/region.test.ts @@ -0,0 +1,107 @@ +import {assembleUnitSelectionSignals} from '../../../src/compile/selection/assemble'; +import region from '../../../src/compile/selection/region'; +import {parseUnitSelection} from '../../../src/compile/selection/parse'; +import {parseUnitModelWithScale} from '../../util'; +import {parseSelector} from 'vega'; + +describe('Multi Selection', () => { + const model = parseUnitModelWithScale({ + mark: 'circle', + encoding: { + x: {field: 'Horsepower', type: 'quantitative'}, + y: {field: 'Miles_per_Gallon', type: 'quantitative', bin: true}, + color: {field: 'Origin', type: 'nominal'} + } + }); + + const selCmpts2 = (model.component.selection = parseUnitSelection(model, [ + { + name: 'one', + select: 'region' + }, + { + name: 'two', + select: { + type: 'region', + mark: { + fill: 'red', + fillOpacity: 0.75, + stroke: 'black', + strokeWidth: 4, + strokeDash: [10, 5] + } + } + } + ])); + + it('builds tuple signals', () => { + const oneSg = region.signals(model, selCmpts2['one'], []); + + expect(oneSg).toEqual( + expect.arrayContaining([ + { + name: 'one_tuple', + on: [ + { + events: [{signal: 'one_screen_path'}], + update: 'vlSelectionTuples(intersectLasso("marks", one_screen_path, unit), {unit: ""})' + } + ] + }, + { + name: 'one_screen_path', + init: '[]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[[x(unit), y(unit)]]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: 'lassoAppend(one_screen_path, clamp(x(unit), 0, width), clamp(y(unit), 0, height))' + } + ] + } + ]) + ); + }); + + it('builds modify signals', () => { + const signals = assembleUnitSelectionSignals(model, []); + + expect(signals).toEqual( + expect.arrayContaining([ + { + name: 'one_modify', + on: [ + { + events: {signal: 'one_tuple'}, + update: `modify("one_store", one_tuple, true)` + } + ] + } + ]) + ); + }); + + it('builds brush mark', () => { + const marks: any[] = []; + + expect(region.marks(model, selCmpts2['two'], marks)).toEqual([ + { + name: 'two_brush', + type: 'path', + encode: { + enter: { + fill: {value: 'red'}, + fillOpacity: {value: 0.75}, + stroke: {value: 'black'}, + strokeWidth: {value: 4}, + strokeDash: {value: [10, 5]} + }, + update: {path: {signal: 'lassoPath(two_screen_path)'}} + } + } + ]); + }); +});