diff --git a/build/vega-lite-schema.json b/build/vega-lite-schema.json index 0ce9879b3a..3f009869f1 100644 --- a/build/vega-lite-schema.json +++ b/build/vega-lite-schema.json @@ -21346,6 +21346,120 @@ }, "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" + }, + "fields": { + "description": "An array of field names whose 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/FieldName" + }, + "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" + }, + "fields": { + "description": "An array of field names whose 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/FieldName" + }, + "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": { @@ -23108,6 +23222,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" @@ -23182,6 +23300,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`." @@ -23221,7 +23342,8 @@ "SelectionType": { "enum": [ "point", - "interval" + "interval", + "region" ], "type": "string" }, @@ -30987,6 +31109,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..8583225ab5 Binary files /dev/null and b/examples/compiled/selection_type_region.png differ diff --git a/examples/compiled/selection_type_region.svg b/examples/compiled/selection_type_region.svg new file mode 100644 index 0000000000..c84dceb9e0 --- /dev/null +++ b/examples/compiled/selection_type_region.svg @@ -0,0 +1 @@ +050100150200Horsepower01020304050Miles_per_Gallon34568Cylinders \ No newline at end of file diff --git a/examples/compiled/selection_type_region.vg.json b/examples/compiled/selection_type_region.vg.json new file mode 100644 index 0000000000..524cddd617 --- /dev/null +++ b/examples/compiled/selection_type_region.vg.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 200, + "height": 200, + "style": "cell", + "data": [ + { + "name": "brush_store", + "transform": [{"type": "collect", "sort": {"field": "_vgsid_"}}] + }, + { + "name": "source_0", + "url": "data/cars.json", + "format": {"type": "json"}, + "transform": [ + {"type": "identifier", "as": "_vgsid_"}, + { + "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": "pointermove", "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_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": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"\"", + "signal": "lassoPath(brush_screen_path)" + }, + {"value": "[]"} + ] + } + } + }, + { + "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\")) || vlSelectionIdTest(\"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..7bb9c0852c 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..5e7f91704b --- /dev/null +++ b/examples/compiled/selection_type_region_concat.svg @@ -0,0 +1 @@ +050100150200Horsepower01020304050Miles_per_Gallon0510152025Acceleration0100200300400500Displacement \ 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..a2e4fc230d --- /dev/null +++ b/examples/compiled/selection_type_region_concat.vg.json @@ -0,0 +1,321 @@ +{ + "$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", + "transform": [{"type": "collect", "sort": {"field": "_vgsid_"}}] + }, + { + "name": "source_0", + "url": "data/cars.json", + "format": {"type": "json"}, + "transform": [{"type": "identifier", "as": "_vgsid_"}] + }, + { + "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": "pointermove", "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_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": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0\"", + "signal": "lassoPath(brush_screen_path)" + }, + {"value": "[]"} + ] + } + } + }, + { + "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 99568b67ea..d16ee804be 100644 --- a/site/_data/examples.json +++ b/site/_data/examples.json @@ -838,6 +838,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/src/compile/selection/index.ts b/src/compile/selection/index.ts index 320541acd1..a1185bd705 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'; @@ -67,6 +68,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..bb0b4cd85b --- /dev/null +++ b/src/compile/selection/region.ts @@ -0,0 +1,113 @@ +import {OnEvent, Signal} from 'vega'; +import {stringValue} from 'vega-util'; +import {SelectionCompiler, STORE, TUPLE, unitName} from '.'; +import {warn} from '../../log'; +import {BRUSH} from './interval'; +import scales from './scales'; +import {SELECTION_ID} from '../../selection'; +export const SCREEN_PATH = '_screen_path'; + +const region: SelectionCompiler<'region'> = { + defined: selCmpt => selCmpt.type === 'region', + + parse: (model, selCmpt, selDef) => { + // Region selections are only valid over the SELECTION_ID field. + // As a result, we don't expose "fields" as a valid property of the interface + // and instead hardwire it here during parsing. + selDef.select = {type: 'region', fields: [SELECTION_ID]} as any; + }, + + 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}`; + const store = `data(${stringValue(selCmpt.name + STORE)})`; + + // Do not add a brush if we're binding to scales. + if (scales.defined(selCmpt)) { + return marks; + } + + const path = {signal: `lassoPath(${screenPathName})`}; + + 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: + // If the selection is resolved to global, only a single region is in the store. + // We wrap a region mark's path encoding with a production rule to hide the mark + // if it corresponds to a unit different from the one in the store. + selCmpt.resolve === 'global' + ? [ + { + test: `${store}.length && ${store}[0].unit === ${unitName(model)}`, + ...path + }, + { + value: '[]' + } + ] + : path + } + } + }, + ...marks + ]; + } +}; + +export default region; diff --git a/src/selection.ts b/src/selection.ts index bf931b0055..f8a6586ed9 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 b58c958d70..d654db5ce1 100644 --- a/test-runtime/index.html +++ b/test-runtime/index.html @@ -38,6 +38,13 @@ ); } + 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 mark(id, parent) { return document.querySelector((parent ? `g.${parent} ` : '') + `g.mark-symbol.role-mark path:nth-child(${id})`); } @@ -47,6 +54,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'); } @@ -67,6 +78,53 @@ return (await view.runAsync()).data('sel_store'); } + async function polygonRegion(id, shape, parent, targetBrush) { + 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(id, radius, segments, parent, targetBrush) { + 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/interval.test.ts b/test-runtime/interval.test.ts index f0b4690ff0..8654c04104 100644 --- a/test-runtime/interval.test.ts +++ b/test-runtime/interval.test.ts @@ -1,5 +1,5 @@ import {TopLevelSpec} from '../src'; -import {SelectionType} from '../src/selection'; +import {SELECTION_ID, SelectionType} from '../src/selection'; import {brush, embedFn, geoSpec, hits as hitsMaster, spec, testRenderFn, tuples} from './util'; import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; @@ -204,7 +204,7 @@ describe('interval selections at runtime in unit views', () => { const store: any = await page.evaluate(brush('drag', 1)); expect(store).toHaveLength(13); for (const t of store) { - expect(t).toHaveProperty('_vgsid_'); + expect(t).toHaveProperty(SELECTION_ID); } await testRender(`geo_1`); }); @@ -214,7 +214,7 @@ describe('interval selections at runtime in unit views', () => { const store: any = await page.evaluate(brush('drag', 0)); expect(store).toHaveLength(20); for (const t of store) { - expect(t).toHaveProperty('_vgsid_'); + expect(t).toHaveProperty(SELECTION_ID); } await testRender(`geo_0`); }); diff --git a/test-runtime/discrete.test.ts b/test-runtime/point.test.ts similarity index 98% rename from test-runtime/discrete.test.ts rename to test-runtime/point.test.ts index f30dc81978..a06a26c307 100644 --- a/test-runtime/discrete.test.ts +++ b/test-runtime/point.test.ts @@ -20,7 +20,7 @@ describe(`point selections at runtime in unit views`, () => { }); const type: SelectionType = 'point'; - const hits = hitsMaster.discrete; + const hits = hitsMaster.point; it('should add values to the store', async () => { for (let i = 0; i < hits.qq.length; i++) { diff --git a/test-runtime/region.test.ts b/test-runtime/region.test.ts new file mode 100644 index 0000000000..2ea652ed48 --- /dev/null +++ b/test-runtime/region.test.ts @@ -0,0 +1,68 @@ +import {TopLevelSpec} from '../src'; +import {SELECTION_ID, SelectionType} from '../src/selection'; +import {clearRegion, embedFn, circleRegion, polygonRegion, spec, testRenderFn, hits} from './util'; +import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; + +describe('region 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('should add values to the store for circle regions', async () => { + for (const [i, hit] of hits.region.circle.entries()) { + await embed(spec('unit', 0, {type})); + const store: any = await page.evaluate(circleRegion(hit.id)); + + expect(store).toHaveLength(hit.count); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + for (const t of store) { + expect(t).toHaveProperty(SELECTION_ID); + } + + await testRender(`circle_${i}`); + } + }); + + it('should add values to the store for complex polygons', async () => { + for (const [i, hit] of hits.region.polygon.entries()) { + await embed(spec('unit', 0, {type})); + const store: any = await page.evaluate(polygonRegion(hit.id, hit.coords)); + + expect(store).toHaveLength(hit.count); + expect(store[0].fields).toBeUndefined(); + expect(store[0].values).toBeUndefined(); + for (const t of store) { + expect(t).toHaveProperty(SELECTION_ID); + } + + await testRender(`polygon_${i}`); + } + }); + + it('should clear out stored extents', async () => { + await embed(spec('unit', 0, {type})); + + const hit = hits.region.circle[0]; + let store = await page.evaluate(circleRegion(hit.id)); + expect(store).toHaveLength(hit.count); + + store = await page.evaluate(clearRegion(hits.region.circle_clear[0].id)); + expect(store).toHaveLength(0); + + await testRender(`clear_0`); + }); +}); diff --git a/test-runtime/resolve.test.ts b/test-runtime/resolve.test.ts index d3045c4488..bd74e17b82 100644 --- a/test-runtime/resolve.test.ts +++ b/test-runtime/resolve.test.ts @@ -3,6 +3,7 @@ import { compositeTypes, embedFn, hits as hitsMaster, + multiviewRegion, parentSelector, pt, resolutions, @@ -14,10 +15,15 @@ import { import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; import {TopLevelSpec} from '../src'; +const fns = { + point: pt, + interval: brush, + region: multiviewRegion +}; + for (const type of selectionTypes) { - const isInterval = type === 'interval'; - const hits: any = isInterval ? hitsMaster.interval : hitsMaster.discrete; - const fn = isInterval ? brush : pt; + const hits = hitsMaster[type]; + const fn = fns[type]; describe(`${type} selections at runtime`, () => { let page: Page; @@ -49,7 +55,7 @@ for (const type of selectionTypes) { const selection = { type, resolve: 'global', - ...(specType === 'facet' ? {encodings: ['y']} : {}) + ...(specType === 'facet' && type !== 'region' ? {encodings: ['y']} : {}) }; for (let i = 0; i < hits[specType].length; i++) { @@ -72,7 +78,7 @@ for (const type of selectionTypes) { const selection = { type, resolve, - ...(specType === 'facet' ? {encodings: ['x']} : {}) + ...(specType === 'facet' && type !== 'region' ? {encodings: ['x']} : {}) }; /** diff --git a/test-runtime/resources/region/facet/global_0.svg b/test-runtime/resources/region/facet/global_0.svg new file mode 100644 index 0000000000..e9b60bbfb9 --- /dev/null +++ b/test-runtime/resources/region/facet/global_0.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/global_1.svg b/test-runtime/resources/region/facet/global_1.svg new file mode 100644 index 0000000000..9914adb83d --- /dev/null +++ b/test-runtime/resources/region/facet/global_1.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/global_2.svg b/test-runtime/resources/region/facet/global_2.svg new file mode 100644 index 0000000000..fc5392f14c --- /dev/null +++ b/test-runtime/resources/region/facet/global_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/global_clear_2.svg b/test-runtime/resources/region/facet/global_clear_2.svg new file mode 100644 index 0000000000..cb25b91108 --- /dev/null +++ b/test-runtime/resources/region/facet/global_clear_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_0.svg b/test-runtime/resources/region/facet/intersect_0.svg new file mode 100644 index 0000000000..eb6da9e8ef --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_0.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_1.svg b/test-runtime/resources/region/facet/intersect_1.svg new file mode 100644 index 0000000000..3312529937 --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_1.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_2.svg b/test-runtime/resources/region/facet/intersect_2.svg new file mode 100644 index 0000000000..9683dc2306 --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_clear_0.svg b/test-runtime/resources/region/facet/intersect_clear_0.svg new file mode 100644 index 0000000000..2884ee4b8e --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_clear_0.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_clear_1.svg b/test-runtime/resources/region/facet/intersect_clear_1.svg new file mode 100644 index 0000000000..be479fff92 --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_clear_1.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/intersect_clear_2.svg b/test-runtime/resources/region/facet/intersect_clear_2.svg new file mode 100644 index 0000000000..b9bfcf6b8c --- /dev/null +++ b/test-runtime/resources/region/facet/intersect_clear_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_0.svg b/test-runtime/resources/region/facet/union_0.svg new file mode 100644 index 0000000000..eb6da9e8ef --- /dev/null +++ b/test-runtime/resources/region/facet/union_0.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_1.svg b/test-runtime/resources/region/facet/union_1.svg new file mode 100644 index 0000000000..5e1dc0206c --- /dev/null +++ b/test-runtime/resources/region/facet/union_1.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_2.svg b/test-runtime/resources/region/facet/union_2.svg new file mode 100644 index 0000000000..c60a631590 --- /dev/null +++ b/test-runtime/resources/region/facet/union_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_clear_0.svg b/test-runtime/resources/region/facet/union_clear_0.svg new file mode 100644 index 0000000000..2884ee4b8e --- /dev/null +++ b/test-runtime/resources/region/facet/union_clear_0.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_clear_1.svg b/test-runtime/resources/region/facet/union_clear_1.svg new file mode 100644 index 0000000000..be479fff92 --- /dev/null +++ b/test-runtime/resources/region/facet/union_clear_1.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/facet/union_clear_2.svg b/test-runtime/resources/region/facet/union_clear_2.svg new file mode 100644 index 0000000000..ed821edf5a --- /dev/null +++ b/test-runtime/resources/region/facet/union_clear_2.svg @@ -0,0 +1 @@ +c012 \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/global_0.svg b/test-runtime/resources/region/repeat/global_0.svg new file mode 100644 index 0000000000..3aa27092c9 --- /dev/null +++ b/test-runtime/resources/region/repeat/global_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/global_1.svg b/test-runtime/resources/region/repeat/global_1.svg new file mode 100644 index 0000000000..79477098ba --- /dev/null +++ b/test-runtime/resources/region/repeat/global_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/global_2.svg b/test-runtime/resources/region/repeat/global_2.svg new file mode 100644 index 0000000000..81dd340021 --- /dev/null +++ b/test-runtime/resources/region/repeat/global_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/global_clear_2.svg b/test-runtime/resources/region/repeat/global_clear_2.svg new file mode 100644 index 0000000000..35d7e51bbf --- /dev/null +++ b/test-runtime/resources/region/repeat/global_clear_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_0.svg b/test-runtime/resources/region/repeat/intersect_0.svg new file mode 100644 index 0000000000..18ba0ea442 --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_1.svg b/test-runtime/resources/region/repeat/intersect_1.svg new file mode 100644 index 0000000000..12d9e4d08e --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_2.svg b/test-runtime/resources/region/repeat/intersect_2.svg new file mode 100644 index 0000000000..b5ed167f3b --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_clear_0.svg b/test-runtime/resources/region/repeat/intersect_clear_0.svg new file mode 100644 index 0000000000..3574852192 --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_clear_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_clear_1.svg b/test-runtime/resources/region/repeat/intersect_clear_1.svg new file mode 100644 index 0000000000..3882d40c82 --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_clear_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/intersect_clear_2.svg b/test-runtime/resources/region/repeat/intersect_clear_2.svg new file mode 100644 index 0000000000..3d2e06adf7 --- /dev/null +++ b/test-runtime/resources/region/repeat/intersect_clear_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_0.svg b/test-runtime/resources/region/repeat/union_0.svg new file mode 100644 index 0000000000..18ba0ea442 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_1.svg b/test-runtime/resources/region/repeat/union_1.svg new file mode 100644 index 0000000000..76b0bb9287 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_2.svg b/test-runtime/resources/region/repeat/union_2.svg new file mode 100644 index 0000000000..f01ab7a7e3 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_clear_0.svg b/test-runtime/resources/region/repeat/union_clear_0.svg new file mode 100644 index 0000000000..3574852192 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_clear_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_clear_1.svg b/test-runtime/resources/region/repeat/union_clear_1.svg new file mode 100644 index 0000000000..3882d40c82 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_clear_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/region/repeat/union_clear_2.svg b/test-runtime/resources/region/repeat/union_clear_2.svg new file mode 100644 index 0000000000..edd1fa5361 --- /dev/null +++ b/test-runtime/resources/region/repeat/union_clear_2.svg @@ -0,0 +1 @@ + \ No newline at end of file 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..b79c887b6e --- /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_1.svg b/test-runtime/resources/region/unit/circle_1.svg new file mode 100644 index 0000000000..33997b39c1 --- /dev/null +++ b/test-runtime/resources/region/unit/circle_1.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..0992ffc4ac --- /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/clear_0.svg b/test-runtime/resources/region/unit/clear_0.svg new file mode 100644 index 0000000000..1b119829d8 --- /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..4a4baa010b --- /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..674738c620 --- /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 c30f2d75fb..5d26ce62fb 100644 --- a/test-runtime/util.ts +++ b/test-runtime/util.ts @@ -10,8 +10,8 @@ const generate = process.env.VL_GENERATE_TESTS; const output = 'test-runtime/resources'; export type ComposeType = 'unit' | 'repeat' | 'facet'; -export const selectionTypes: SelectionType[] = ['point', 'interval']; -export const compositeTypes = ['repeat', 'facet'] as const; +export const selectionTypes: SelectionType[] = ['point', 'interval', 'region']; +export const compositeTypes: ComposeType[] = ['repeat', 'facet']; export const resolutions: SelectionResolution[] = ['union', 'intersect']; export const bound = 'bound'; @@ -56,7 +56,7 @@ const UNIT_NAMES = { }; export const hits = { - discrete: { + point: { qq: [8, 19], qq_clear: [5, 16], @@ -104,6 +104,48 @@ export const hits = { [4, 10] ], facet_clear: [[3], [5], [7]] + }, + + region: { + circle: [ + {id: 14, count: 5}, + {id: 3, count: 2}, + {id: 6, count: 4} + ], + circle_clear: [{id: 14}], + + polygon: [ + { + id: 6, + coords: [ + [-30, -30], + [-30, 30], + [30, 30], + [30, -30] + ], + count: 4 + }, + { + id: 14, + coords: [ + [-30, -30], + [-30, 30], + [-15, 15], + [-15, -15], + [15, -15], + [15, 30], + [30, 30], + [30, -30] + ], + count: 2 + } + ], + + facet: [2, 4, 7], + facet_clear: [3, 5, 8], + + repeat: [5, 10, 16], + repeat_clear: [13, 14, 2] } }; @@ -259,14 +301,57 @@ export function parentSelector(compositeType: ComposeType, index: number) { } export type BrushKeys = keyof typeof hits.interval; + +export function clear(idx: number, parent?: string, targetBrush?: boolean) { + return `pureClear(${idx}, ${stringValue(parent)}, ${!!targetBrush})`; +} + +export function clearRegion( + idx: + | number + | { + id: number; + count?: number; + }, + parent?: string, + targetBrush?: boolean +) { + return `clear(${idx}, ${stringValue(parent)}, ${!!targetBrush})`; +} + +export function circleRegion( + idx: + | number + | { + id: number; + count?: number; + }, + parent?: string, + targetBrush?: boolean, + radius = 40, + segments = 20 +) { + return `circleRegion(${idx}, ${radius}, ${segments}, ${stringValue(parent)}, ${!!targetBrush})`; +} + +export function polygonRegion(idx: number, polygon: number[][], parent?: string, targetBrush?: boolean) { + return `polygonRegion(${idx}, ${JSON.stringify(polygon)}, ${stringValue(parent)}, ${!!targetBrush})`; +} + +export function multiviewRegion(key: keyof typeof hits.region, idx: number, parent?: string, targetBrush?: boolean) { + return key.match('_clear') + ? clearRegion(hits.region[key][idx], parent, targetBrush) + : circleRegion(hits.region[key][idx], parent, targetBrush, 10); +} + export function brush(key: BrushKeys, idx: number, parent?: string, targetBrush?: boolean) { const fn = key.match('_clear') ? 'clear' : 'brush'; return `${fn}(${hits.interval[key][idx].join(', ')}, ${stringValue(parent)}, ${!!targetBrush})`; } -export function pt(key: keyof typeof hits.discrete, idx: number, parent?: string) { +export function pt(key: keyof typeof hits.point, idx: number, parent?: string) { const fn = key.match('_clear') ? 'clear' : 'pt'; - return `${fn}(${hits.discrete[key][idx]}, ${stringValue(parent)})`; + return `${fn}(${hits.point[key][idx]}, ${stringValue(parent)})`; } export function getState(signals: string[], data: string[]) { diff --git a/test/compile/selection/region.test.ts b/test/compile/selection/region.test.ts new file mode 100644 index 0000000000..bffdf38dc4 --- /dev/null +++ b/test/compile/selection/region.test.ts @@ -0,0 +1,115 @@ +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: [ + { + test: 'data("two_store").length && data("two_store")[0].unit === ""', + signal: 'lassoPath(two_screen_path)' + }, + {value: '[]'} + ] + } + } + } + ]); + }); +});