diff --git a/examples/compiled/airport_connections.svg b/examples/compiled/airport_connections.svg index 725e6af54d..3489a11df3 100644 --- a/examples/compiled/airport_connections.svg +++ b/examples/compiled/airport_connections.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/examples/compiled/airport_connections.vg.json b/examples/compiled/airport_connections.vg.json index e7ebab19a6..ebce8e1b48 100644 --- a/examples/compiled/airport_connections.vg.json +++ b/examples/compiled/airport_connections.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 900, "height": 500, + "style": "view", "data": [ {"name": "org_store"}, { @@ -177,7 +178,7 @@ "name": "layer_0_marks", "type": "shape", "style": ["geoshape"], - "interactive": false, + "interactive": true, "from": {"data": "source_0"}, "encode": { "update": { diff --git a/examples/compiled/arc_donut.vg.json b/examples/compiled/arc_donut.vg.json index 389e13d5ef..60ec25b55f 100644 --- a/examples/compiled/arc_donut.vg.json +++ b/examples/compiled/arc_donut.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/arc_facet.vg.json b/examples/compiled/arc_facet.vg.json index 89f6d05fb8..d88dc740bc 100644 --- a/examples/compiled/arc_facet.vg.json +++ b/examples/compiled/arc_facet.vg.json @@ -72,6 +72,7 @@ { "name": "cell", "type": "group", + "style": "view", "from": { "facet": {"name": "facet", "data": "source_0", "groupby": ["year"]} }, diff --git a/examples/compiled/arc_ordinal_theta.vg.json b/examples/compiled/arc_ordinal_theta.vg.json index b8aed31ada..3a2a46e8e9 100644 --- a/examples/compiled/arc_ordinal_theta.vg.json +++ b/examples/compiled/arc_ordinal_theta.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/arc_params.vg.json b/examples/compiled/arc_params.vg.json index 14d707be61..0af536c4cd 100644 --- a/examples/compiled/arc_params.vg.json +++ b/examples/compiled/arc_params.vg.json @@ -80,6 +80,7 @@ "type": "group", "name": "concat_0_group", "title": {"text": "Single Arc", "frame": "group"}, + "style": "view", "encode": { "update": { "width": {"signal": "childWidth"}, @@ -114,6 +115,7 @@ "type": "group", "name": "concat_1_group", "title": {"text": "Stacked Arcs", "frame": "group"}, + "style": "view", "encode": { "update": { "width": {"signal": "childWidth"}, diff --git a/examples/compiled/arc_pie.vg.json b/examples/compiled/arc_pie.vg.json index f55c0ef74f..eb9e150a1b 100644 --- a/examples/compiled/arc_pie.vg.json +++ b/examples/compiled/arc_pie.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/arc_pie_pyramid.vg.json b/examples/compiled/arc_pie_pyramid.vg.json index e39ac16596..e49b5d398d 100644 --- a/examples/compiled/arc_pie_pyramid.vg.json +++ b/examples/compiled/arc_pie_pyramid.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/arc_radial.vg.json b/examples/compiled/arc_radial.vg.json index eb72aad8ad..8a23181fe6 100644 --- a/examples/compiled/arc_radial.vg.json +++ b/examples/compiled/arc_radial.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ {"name": "source_0", "values": [12, 23, 47, 6, 52, 19]}, { diff --git a/examples/compiled/arc_radial_histogram.vg.json b/examples/compiled/arc_radial_histogram.vg.json index 485e9da81f..3211200218 100644 --- a/examples/compiled/arc_radial_histogram.vg.json +++ b/examples/compiled/arc_radial_histogram.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_choropleth.vg.json b/examples/compiled/geo_choropleth.vg.json index b6b9285091..857c5be0c1 100644 --- a/examples/compiled/geo_choropleth.vg.json +++ b/examples/compiled/geo_choropleth.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ { "name": "source_1", diff --git a/examples/compiled/geo_circle.vg.json b/examples/compiled/geo_circle.vg.json index d241713686..928b5138ea 100644 --- a/examples/compiled/geo_circle.vg.json +++ b/examples/compiled/geo_circle.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_constant_value.vg.json b/examples/compiled/geo_constant_value.vg.json index b26c519b0a..ebf40947e0 100644 --- a/examples/compiled/geo_constant_value.vg.json +++ b/examples/compiled/geo_constant_value.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ {"name": "source_0", "url": "data/airports.csv", "format": {"type": "csv"}}, { diff --git a/examples/compiled/geo_custom_projection.vg.json b/examples/compiled/geo_custom_projection.vg.json index ccbb878b68..11e3a1680d 100644 --- a/examples/compiled/geo_custom_projection.vg.json +++ b/examples/compiled/geo_custom_projection.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 900, "height": 560, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_graticule.vg.json b/examples/compiled/geo_graticule.vg.json index b99cca1b04..4383dbdff4 100644 --- a/examples/compiled/geo_graticule.vg.json +++ b/examples/compiled/geo_graticule.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [{"name": "source_0", "transform": [{"type": "graticule"}]}], "projections": [ { diff --git a/examples/compiled/geo_graticule_object.vg.json b/examples/compiled/geo_graticule_object.vg.json index 3f9782c791..80d5094d91 100644 --- a/examples/compiled/geo_graticule_object.vg.json +++ b/examples/compiled/geo_graticule_object.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ {"name": "source_0", "transform": [{"type": "graticule", "step": [15, 15]}]} ], diff --git a/examples/compiled/geo_layer.vg.json b/examples/compiled/geo_layer.vg.json index 7ca74aec0f..b2b66955c1 100644 --- a/examples/compiled/geo_layer.vg.json +++ b/examples/compiled/geo_layer.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_layer_line_london.vg.json b/examples/compiled/geo_layer_line_london.vg.json index 2811207595..c2bf00c0f3 100644 --- a/examples/compiled/geo_layer_line_london.vg.json +++ b/examples/compiled/geo_layer_line_london.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 700, "height": 500, + "style": "view", "encode": {"update": {"stroke": {"value": "transparent"}}}, "data": [ { diff --git a/examples/compiled/geo_line.vg.json b/examples/compiled/geo_line.vg.json index ee3bcb803a..6220f8efc7 100644 --- a/examples/compiled/geo_line.vg.json +++ b/examples/compiled/geo_line.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 800, "height": 500, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_params_projections.vg.json b/examples/compiled/geo_params_projections.vg.json index d8933df702..03b8102be8 100644 --- a/examples/compiled/geo_params_projections.vg.json +++ b/examples/compiled/geo_params_projections.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_point.vg.json b/examples/compiled/geo_point.vg.json index 1ae2c7c509..4991440538 100644 --- a/examples/compiled/geo_point.vg.json +++ b/examples/compiled/geo_point.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 500, "height": 300, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_repeat.vg.json b/examples/compiled/geo_repeat.vg.json index ab1e762614..52b141ba78 100644 --- a/examples/compiled/geo_repeat.vg.json +++ b/examples/compiled/geo_repeat.vg.json @@ -101,6 +101,7 @@ { "type": "group", "name": "child__row_population_group", + "style": "view", "encode": { "update": { "width": {"signal": "width"}, @@ -145,6 +146,7 @@ { "type": "group", "name": "child__row_engineers_group", + "style": "view", "encode": { "update": { "width": {"signal": "width"}, @@ -189,6 +191,7 @@ { "type": "group", "name": "child__row_hurricanes_group", + "style": "view", "encode": { "update": { "width": {"signal": "width"}, diff --git a/examples/compiled/geo_rule.vg.json b/examples/compiled/geo_rule.vg.json index 3fbd119fab..a1e005b345 100644 --- a/examples/compiled/geo_rule.vg.json +++ b/examples/compiled/geo_rule.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 800, "height": 500, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_sphere.vg.json b/examples/compiled/geo_sphere.vg.json index 1665877a97..d1f0cc0189 100644 --- a/examples/compiled/geo_sphere.vg.json +++ b/examples/compiled/geo_sphere.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ {"name": "source_0", "values": [{"type": "Sphere"}]}, {"name": "source_1", "transform": [{"type": "graticule"}]} diff --git a/examples/compiled/geo_text.vg.json b/examples/compiled/geo_text.vg.json index fdf4712a9d..070f566b95 100644 --- a/examples/compiled/geo_text.vg.json +++ b/examples/compiled/geo_text.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 800, "height": 500, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/geo_trellis.vg.json b/examples/compiled/geo_trellis.vg.json index 3da339cb3d..23073cb4ae 100644 --- a/examples/compiled/geo_trellis.vg.json +++ b/examples/compiled/geo_trellis.vg.json @@ -85,6 +85,7 @@ { "name": "cell", "type": "group", + "style": "view", "from": { "facet": {"name": "facet", "data": "source_0", "groupby": ["group"]} }, diff --git a/examples/compiled/interactive_1d_geo_brush.png b/examples/compiled/interactive_1d_geo_brush.png new file mode 100644 index 0000000000..17dfd8f159 Binary files /dev/null and b/examples/compiled/interactive_1d_geo_brush.png differ diff --git a/examples/compiled/interactive_1d_geo_brush.svg b/examples/compiled/interactive_1d_geo_brush.svg new file mode 100644 index 0000000000..d7558f3c32 --- /dev/null +++ b/examples/compiled/interactive_1d_geo_brush.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/compiled/interactive_1d_geo_brush.vg.json b/examples/compiled/interactive_1d_geo_brush.vg.json new file mode 100644 index 0000000000..cc4abb825d --- /dev/null +++ b/examples/compiled/interactive_1d_geo_brush.vg.json @@ -0,0 +1,346 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "width": 500, + "height": 300, + "style": "view", + "data": [ + { + "name": "brush_store", + "transform": [{"type": "collect", "sort": {"field": "_vgsid_"}}], + "values": [{"unit": "layer_1", "_vgsid_": [45, 51.5]}] + }, + { + "name": "source_0", + "url": "data/us-10m.json", + "format": {"type": "topojson", "feature": "states"}, + "transform": [{"type": "identifier", "as": "_vgsid_"}] + }, + { + "name": "source_1", + "url": "data/airports.csv", + "format": {"type": "csv"}, + "transform": [ + {"type": "identifier", "as": "_vgsid_"}, + { + "type": "filter", + "expr": "datum.state !== 'PR' && datum.state !== 'VI'" + }, + { + "type": "geojson", + "fields": ["longitude", "latitude"], + "signal": "layer_1_geojson_0" + }, + { + "type": "geopoint", + "projection": "projection", + "fields": ["longitude", "latitude"], + "as": ["layer_1_x", "layer_1_y"] + } + ] + } + ], + "projections": [ + { + "name": "projection", + "size": {"signal": "[width, height]"}, + "fit": {"signal": "[data('source_0'), layer_1_geojson_0]"}, + "type": "albersUsa" + } + ], + "signals": [ + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "geo_interval_init_tick", + "value": null, + "on": [ + { + "events": "timer{1}", + "update": "geo_interval_init_tick === null ? {} : geo_interval_init_tick" + } + ] + }, + { + "name": "brush", + "update": "vlSelectionResolve(\"brush_store\", \"union\")" + }, + { + "name": "projection_center", + "update": "invert(\"projection\", [width/2, height/2])" + }, + { + "name": "brush_init", + "init": "[scale(\"projection\", [projection_center[0], 45]), scale(\"projection\", [projection_center[0], 51.5])]" + }, + { + "name": "brush_latitude_1", + "init": "[brush_init[0][1], brush_init[1][1]]", + "on": [ + { + "events": { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + "update": "[y(unit), y(unit)]" + }, + { + "events": { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + {"source": "window", "type": "mouseup"} + ] + }, + "update": "[brush_latitude_1[0], clamp(y(unit), 0, height)]" + }, + { + "events": [{"source": "view", "type": "dblclick"}], + "update": "[0, 0]" + }, + { + "events": {"signal": "brush_translate_delta"}, + "update": "clampRange(panLinear(brush_translate_anchor.extent_y, brush_translate_delta.y / span(brush_translate_anchor.extent_y)), 0, height)" + }, + { + "events": {"signal": "brush_zoom_delta"}, + "update": "clampRange(zoomLinear(brush_latitude_1, brush_zoom_anchor.y, brush_zoom_delta), 0, height)" + } + ] + }, + { + "name": "brush_tuple", + "on": [ + { + "events": [ + {"signal": "brush_latitude_1"}, + {"signal": "geo_interval_init_tick"} + ], + "update": "vlSelectionTuples(intersect([[0, brush_latitude_1[0]],[width, brush_latitude_1[1]]], {markname: \"layer_1_marks\"}, unit.mark), {unit: \"layer_1\"})" + } + ] + }, + { + "name": "brush_translate_anchor", + "value": {}, + "on": [ + { + "events": [ + {"source": "scope", "type": "mousedown", "markname": "brush_brush"} + ], + "update": "{x: x(unit), y: y(unit), extent_y: slice(brush_latitude_1)}" + } + ] + }, + { + "name": "brush_translate_delta", + "value": {}, + "on": [ + { + "events": [ + { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + { + "source": "scope", + "type": "mousedown", + "markname": "brush_brush" + }, + {"source": "window", "type": "mouseup"} + ] + } + ], + "update": "{x: brush_translate_anchor.x - x(unit), y: brush_translate_anchor.y - y(unit)}" + } + ] + }, + { + "name": "brush_zoom_anchor", + "on": [ + { + "events": [ + { + "source": "scope", + "type": "wheel", + "consume": true, + "markname": "brush_brush" + } + ], + "update": "{x: x(unit), y: y(unit)}" + } + ] + }, + { + "name": "brush_zoom_delta", + "on": [ + { + "events": [ + { + "source": "scope", + "type": "wheel", + "consume": true, + "markname": "brush_brush" + } + ], + "force": true, + "update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))" + } + ] + }, + { + "name": "brush_modify", + "on": [ + { + "events": {"signal": "brush_tuple"}, + "update": "modify(\"brush_store\", brush_tuple, true)" + } + ] + } + ], + "marks": [ + { + "name": "brush_brush_bg", + "type": "rect", + "clip": true, + "encode": { + "enter": {"fill": {"value": "#333"}, "fillOpacity": {"value": 0.125}}, + "update": { + "x": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "value": 0 + }, + {"value": 0} + ], + "y": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "signal": "brush_latitude_1[0]" + }, + {"value": 0} + ], + "x2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "field": {"group": "width"} + }, + {"value": 0} + ], + "y2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "signal": "brush_latitude_1[1]" + }, + {"value": 0} + ] + } + } + }, + { + "name": "layer_0_marks", + "type": "shape", + "style": ["geoshape"], + "interactive": true, + "from": {"data": "source_0"}, + "encode": { + "update": { + "fill": {"value": "lightgray"}, + "stroke": {"value": "white"}, + "ariaRoleDescription": {"value": "geoshape"} + } + }, + "transform": [{"type": "geoshape", "projection": "projection"}] + }, + { + "name": "layer_1_marks", + "type": "symbol", + "style": ["circle"], + "interactive": true, + "from": {"data": "source_1"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": [ + { + "test": "length(data(\"brush_store\")) && vlSelectionIdTest(\"brush_store\", datum)", + "value": "goldenrod" + }, + {"value": "steelblue"} + ], + "ariaRoleDescription": {"value": "circle"}, + "description": { + "signal": "\"longitude: \" + (format(datum[\"longitude\"], \"\")) + \"; latitude: \" + (format(datum[\"latitude\"], \"\"))" + }, + "x": {"field": "layer_1_x"}, + "y": {"field": "layer_1_y"}, + "size": {"value": 10}, + "shape": {"value": "circle"} + } + } + }, + { + "name": "brush_brush", + "type": "rect", + "clip": true, + "encode": { + "enter": {"fill": {"value": "transparent"}}, + "update": { + "x": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "value": 0 + }, + {"value": 0} + ], + "y": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "signal": "brush_latitude_1[0]" + }, + {"value": 0} + ], + "x2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "field": {"group": "width"} + }, + {"value": 0} + ], + "y2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"", + "signal": "brush_latitude_1[1]" + }, + {"value": 0} + ], + "stroke": [ + { + "test": "brush_latitude_1[0] !== brush_latitude_1[1]", + "value": "white" + }, + {"value": null} + ] + } + } + } + ] +} diff --git a/examples/compiled/interactive_airport_crossfilter.png b/examples/compiled/interactive_airport_crossfilter.png new file mode 100644 index 0000000000..416f6efead Binary files /dev/null and b/examples/compiled/interactive_airport_crossfilter.png differ diff --git a/examples/compiled/interactive_airport_crossfilter.svg b/examples/compiled/interactive_airport_crossfilter.svg new file mode 100644 index 0000000000..216bb5f98b --- /dev/null +++ b/examples/compiled/interactive_airport_crossfilter.svg @@ -0,0 +1 @@ +02004006008001,0001,2001,4001,6001,8002,0002,2002,400distance (binned)02004006008001,000Count of Records−40−20020406080100120140160180200220240260delay (binned)0200400600800Count of Records \ No newline at end of file diff --git a/examples/compiled/interactive_airport_crossfilter.vg.json b/examples/compiled/interactive_airport_crossfilter.vg.json new file mode 100644 index 0000000000..a5840d1077 --- /dev/null +++ b/examples/compiled/interactive_airport_crossfilter.vg.json @@ -0,0 +1,841 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "background": "white", + "padding": 5, + "data": [ + { + "name": "brush_store", + "transform": [{"type": "collect", "sort": {"field": "_vgsid_"}}], + "values": [{"unit": "concat_0_layer_1", "_vgsid_": [30, 40]}] + }, + {"name": "source_2", "url": "data/airports.csv", "format": {"type": "csv"}}, + { + "name": "source_0", + "url": "data/us-10m.json", + "format": {"type": "topojson", "feature": "states"}, + "transform": [{"type": "identifier", "as": "_vgsid_"}] + }, + { + "name": "source_1", + "url": "data/flights-airport.csv", + "format": {"type": "csv"}, + "transform": [ + {"type": "identifier", "as": "_vgsid_"}, + { + "type": "aggregate", + "groupby": ["origin"], + "ops": ["count"], + "fields": [null], + "as": ["routes"] + }, + {"type": "identifier", "as": "_vgsid_"}, + { + "type": "lookup", + "from": "source_2", + "key": "iata", + "fields": ["origin"], + "values": ["state", "latitude", "longitude"] + }, + { + "type": "filter", + "expr": "datum.state !== 'PR' && datum.state !== 'VI'" + }, + { + "type": "geojson", + "fields": ["longitude", "latitude"], + "signal": "concat_0_layer_1_geojson_0" + }, + { + "type": "geopoint", + "projection": "projection", + "fields": ["longitude", "latitude"], + "as": ["concat_0_layer_1_x", "concat_0_layer_1_y"] + }, + { + "type": "filter", + "expr": "isValid(datum[\"routes\"]) && isFinite(+datum[\"routes\"])" + } + ] + }, + { + "name": "data_0", + "source": "source_1", + "transform": [ + { + "type": "filter", + "expr": "!length(data(\"brush_store\")) || vlSelectionIdTest(\"brush_store\", datum)" + } + ] + }, + { + "name": "source_3", + "url": "data/flights-2k.json", + "format": {"type": "json", "parse": {"date": "date"}}, + "transform": [ + {"type": "identifier", "as": "_vgsid_"}, + { + "type": "extent", + "field": "delay", + "signal": "child__column_delay_layer_1_bin_maxbins_20_delay_extent" + }, + { + "type": "bin", + "field": "delay", + "as": ["bin_maxbins_20_delay", "bin_maxbins_20_delay_end"], + "signal": "child__column_delay_layer_1_bin_maxbins_20_delay_bins", + "extent": { + "signal": "child__column_delay_layer_1_bin_maxbins_20_delay_extent" + }, + "maxbins": 20 + }, + { + "type": "extent", + "field": "distance", + "signal": "child__column_distance_layer_0_bin_maxbins_20_distance_extent" + }, + { + "type": "bin", + "field": "distance", + "as": ["bin_maxbins_20_distance", "bin_maxbins_20_distance_end"], + "signal": "child__column_distance_layer_0_bin_maxbins_20_distance_bins", + "extent": { + "signal": "child__column_distance_layer_0_bin_maxbins_20_distance_extent" + }, + "maxbins": 20 + }, + { + "type": "lookup", + "from": "data_0", + "key": "origin", + "fields": ["origin"], + "as": ["brush"] + } + ] + }, + { + "name": "data_1", + "source": "source_3", + "transform": [ + { + "type": "filter", + "expr": "data('brush_store').length && isValid(datum.brush)" + } + ] + }, + { + "name": "data_2", + "source": "data_1", + "transform": [ + { + "type": "aggregate", + "groupby": ["bin_maxbins_20_delay", "bin_maxbins_20_delay_end"], + "ops": ["count"], + "fields": [null], + "as": ["__count"] + }, + { + "type": "filter", + "expr": "isValid(datum[\"bin_maxbins_20_delay\"]) && isFinite(+datum[\"bin_maxbins_20_delay\"])" + } + ] + }, + { + "name": "data_3", + "source": "data_1", + "transform": [ + { + "type": "aggregate", + "groupby": ["bin_maxbins_20_distance", "bin_maxbins_20_distance_end"], + "ops": ["count"], + "fields": [null], + "as": ["__count"] + }, + { + "type": "filter", + "expr": "isValid(datum[\"bin_maxbins_20_distance\"]) && isFinite(+datum[\"bin_maxbins_20_distance\"])" + } + ] + }, + { + "name": "data_4", + "source": "source_3", + "transform": [ + { + "type": "aggregate", + "groupby": ["bin_maxbins_20_distance", "bin_maxbins_20_distance_end"], + "ops": ["count"], + "fields": [null], + "as": ["__count"] + }, + { + "type": "filter", + "expr": "isValid(datum[\"bin_maxbins_20_distance\"]) && isFinite(+datum[\"bin_maxbins_20_distance\"])" + } + ] + }, + { + "name": "data_5", + "source": "source_3", + "transform": [ + { + "type": "aggregate", + "groupby": ["bin_maxbins_20_delay", "bin_maxbins_20_delay_end"], + "ops": ["count"], + "fields": [null], + "as": ["__count"] + }, + { + "type": "filter", + "expr": "isValid(datum[\"bin_maxbins_20_delay\"]) && isFinite(+datum[\"bin_maxbins_20_delay\"])" + } + ] + } + ], + "projections": [ + { + "name": "projection", + "size": {"signal": "[concat_0_width, concat_0_height]"}, + "fit": {"signal": "[data('source_0'), concat_0_layer_1_geojson_0]"}, + "type": "albersUsa" + } + ], + "signals": [ + {"name": "concat_0_width", "value": 500}, + {"name": "concat_0_height", "value": 300}, + {"name": "concat_1_childWidth", "value": 200}, + {"name": "concat_1_childHeight", "value": 200}, + { + "name": "unit", + "value": {}, + "on": [ + {"events": "mousemove", "update": "isTuple(group()) ? group() : unit"} + ] + }, + { + "name": "geo_interval_init_tick", + "value": null, + "on": [ + { + "events": "timer{1}", + "update": "geo_interval_init_tick === null ? {} : geo_interval_init_tick" + } + ] + }, + { + "name": "brush", + "update": "vlSelectionResolve(\"brush_store\", \"union\")" + } + ], + "layout": {"padding": 20, "columns": 1, "bounds": "full", "align": "each"}, + "marks": [ + { + "type": "group", + "name": "concat_0_group", + "style": "view", + "encode": { + "update": { + "width": {"signal": "concat_0_width"}, + "height": {"signal": "concat_0_height"} + } + }, + "signals": [ + { + "name": "brush_init", + "init": "[scale(\"projection\", [-86, 30]), scale(\"projection\", [-118, 40])]" + }, + { + "name": "brush_latitude_1", + "init": "[brush_init[0][1], brush_init[1][1]]", + "on": [ + { + "events": { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + "update": "[y(unit), y(unit)]" + }, + { + "events": { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + {"source": "window", "type": "mouseup"} + ] + }, + "update": "[brush_latitude_1[0], clamp(y(unit), 0, concat_0_height)]" + }, + { + "events": [{"source": "view", "type": "dblclick"}], + "update": "[0, 0]" + }, + { + "events": {"signal": "brush_translate_delta"}, + "update": "clampRange(panLinear(brush_translate_anchor.extent_y, brush_translate_delta.y / span(brush_translate_anchor.extent_y)), 0, concat_0_height)" + }, + { + "events": {"signal": "brush_zoom_delta"}, + "update": "clampRange(zoomLinear(brush_latitude_1, brush_zoom_anchor.y, brush_zoom_delta), 0, concat_0_height)" + } + ] + }, + { + "name": "brush_longitude_1", + "init": "[brush_init[0][0], brush_init[1][0]]", + "on": [ + { + "events": { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + "update": "[x(unit), x(unit)]" + }, + { + "events": { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + { + "source": "scope", + "type": "mousedown", + "filter": [ + "!event.item || event.item.mark.name !== \"brush_brush\"" + ] + }, + {"source": "window", "type": "mouseup"} + ] + }, + "update": "[brush_longitude_1[0], clamp(x(unit), 0, concat_0_width)]" + }, + { + "events": [{"source": "view", "type": "dblclick"}], + "update": "[0, 0]" + }, + { + "events": {"signal": "brush_translate_delta"}, + "update": "clampRange(panLinear(brush_translate_anchor.extent_x, brush_translate_delta.x / span(brush_translate_anchor.extent_x)), 0, concat_0_width)" + }, + { + "events": {"signal": "brush_zoom_delta"}, + "update": "clampRange(zoomLinear(brush_longitude_1, brush_zoom_anchor.x, brush_zoom_delta), 0, concat_0_width)" + } + ] + }, + { + "name": "brush_tuple", + "on": [ + { + "events": [ + {"signal": "brush_latitude_1 || brush_longitude_1"}, + {"signal": "geo_interval_init_tick"} + ], + "update": "vlSelectionTuples(intersect([[brush_longitude_1[0], brush_latitude_1[0]],[brush_longitude_1[1], brush_latitude_1[1]]], {markname: \"concat_0_layer_1_marks\"}, unit.mark), {unit: \"concat_0_layer_1\"})" + } + ] + }, + { + "name": "brush_translate_anchor", + "value": {}, + "on": [ + { + "events": [ + { + "source": "scope", + "type": "mousedown", + "markname": "brush_brush" + } + ], + "update": "{x: x(unit), y: y(unit), extent_x: slice(brush_longitude_1), extent_y: slice(brush_latitude_1)}" + } + ] + }, + { + "name": "brush_translate_delta", + "value": {}, + "on": [ + { + "events": [ + { + "source": "window", + "type": "mousemove", + "consume": true, + "between": [ + { + "source": "scope", + "type": "mousedown", + "markname": "brush_brush" + }, + {"source": "window", "type": "mouseup"} + ] + } + ], + "update": "{x: brush_translate_anchor.x - x(unit), y: brush_translate_anchor.y - y(unit)}" + } + ] + }, + { + "name": "brush_zoom_anchor", + "on": [ + { + "events": [ + { + "source": "scope", + "type": "wheel", + "consume": true, + "markname": "brush_brush" + } + ], + "update": "{x: x(unit), y: y(unit)}" + } + ] + }, + { + "name": "brush_zoom_delta", + "on": [ + { + "events": [ + { + "source": "scope", + "type": "wheel", + "consume": true, + "markname": "brush_brush" + } + ], + "force": true, + "update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))" + } + ] + }, + { + "name": "brush_modify", + "on": [ + { + "events": {"signal": "brush_tuple"}, + "update": "modify(\"brush_store\", brush_tuple, true)" + } + ] + } + ], + "marks": [ + { + "name": "brush_brush_bg", + "type": "rect", + "clip": true, + "encode": { + "enter": { + "fill": {"value": "#333"}, + "fillOpacity": {"value": 0.125} + }, + "update": { + "x": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_longitude_1[0]" + }, + {"value": 0} + ], + "y": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_latitude_1[0]" + }, + {"value": 0} + ], + "x2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_longitude_1[1]" + }, + {"value": 0} + ], + "y2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_latitude_1[1]" + }, + {"value": 0} + ] + } + } + }, + { + "name": "concat_0_layer_0_marks", + "type": "shape", + "style": ["geoshape"], + "interactive": true, + "from": {"data": "source_0"}, + "encode": { + "update": { + "fill": {"value": "lightgray"}, + "stroke": {"value": "white"}, + "ariaRoleDescription": {"value": "geoshape"} + } + }, + "transform": [{"type": "geoshape", "projection": "projection"}] + }, + { + "name": "concat_0_layer_1_marks", + "type": "symbol", + "style": ["circle"], + "interactive": true, + "from": {"data": "source_1"}, + "encode": { + "update": { + "opacity": {"value": 0.7}, + "fill": [ + { + "test": "length(data(\"brush_store\")) && vlSelectionIdTest(\"brush_store\", datum)", + "value": "goldenrod" + }, + {"value": "steelblue"} + ], + "ariaRoleDescription": {"value": "circle"}, + "description": { + "signal": "\"longitude: \" + (format(datum[\"longitude\"], \"\")) + \"; latitude: \" + (format(datum[\"latitude\"], \"\")) + \"; routes: \" + (format(datum[\"routes\"], \"\"))" + }, + "x": {"field": "concat_0_layer_1_x"}, + "y": {"field": "concat_0_layer_1_y"}, + "size": {"scale": "size", "field": "routes"}, + "shape": {"value": "circle"} + } + } + }, + { + "name": "brush_brush", + "type": "rect", + "clip": true, + "encode": { + "enter": {"fill": {"value": "transparent"}}, + "update": { + "x": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_longitude_1[0]" + }, + {"value": 0} + ], + "y": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_latitude_1[0]" + }, + {"value": 0} + ], + "x2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_longitude_1[1]" + }, + {"value": 0} + ], + "y2": [ + { + "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"concat_0_layer_1\"", + "signal": "brush_latitude_1[1]" + }, + {"value": 0} + ], + "stroke": [ + { + "test": "brush_longitude_1[0] !== brush_longitude_1[1] && brush_latitude_1[0] !== brush_latitude_1[1]", + "value": "white" + }, + {"value": null} + ] + } + } + } + ] + }, + { + "type": "group", + "name": "concat_1_group", + "layout": {"padding": 20, "columns": 2, "bounds": "full", "align": "all"}, + "marks": [ + { + "type": "group", + "name": "child__column_distance_group", + "style": "cell", + "encode": { + "update": { + "width": {"signal": "concat_1_childWidth"}, + "height": {"signal": "concat_1_childHeight"} + } + }, + "marks": [ + { + "name": "child__column_distance_layer_0_marks", + "type": "rect", + "style": ["bar"], + "interactive": false, + "from": {"data": "data_4"}, + "encode": { + "update": { + "fill": {"value": "steelblue"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"distance (binned): \" + (!isValid(datum[\"bin_maxbins_20_distance\"]) || !isFinite(+datum[\"bin_maxbins_20_distance\"]) ? \"null\" : format(datum[\"bin_maxbins_20_distance\"], \"\") + \" – \" + format(datum[\"bin_maxbins_20_distance_end\"], \"\")) + \"; Count of Records: \" + (format(datum[\"__count\"], \"\"))" + }, + "x2": { + "scale": "child__column_distance_x", + "field": "bin_maxbins_20_distance", + "offset": 1 + }, + "x": { + "scale": "child__column_distance_x", + "field": "bin_maxbins_20_distance_end" + }, + "y": { + "scale": "child__column_distance_y", + "field": "__count" + }, + "y2": {"scale": "child__column_distance_y", "value": 0} + } + } + }, + { + "name": "child__column_distance_layer_1_marks", + "type": "rect", + "style": ["bar"], + "interactive": false, + "from": {"data": "data_3"}, + "encode": { + "update": { + "fill": {"value": "goldenrod"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"distance (binned): \" + (!isValid(datum[\"bin_maxbins_20_distance\"]) || !isFinite(+datum[\"bin_maxbins_20_distance\"]) ? \"null\" : format(datum[\"bin_maxbins_20_distance\"], \"\") + \" – \" + format(datum[\"bin_maxbins_20_distance_end\"], \"\")) + \"; Count of Records: \" + (format(datum[\"__count\"], \"\"))" + }, + "x2": { + "scale": "child__column_distance_x", + "field": "bin_maxbins_20_distance", + "offset": 1 + }, + "x": { + "scale": "child__column_distance_x", + "field": "bin_maxbins_20_distance_end" + }, + "y": { + "scale": "child__column_distance_y", + "field": "__count" + }, + "y2": {"scale": "child__column_distance_y", "value": 0} + } + } + } + ], + "axes": [ + { + "scale": "child__column_distance_y", + "orient": "left", + "gridScale": "child__column_distance_x", + "grid": true, + "tickCount": {"signal": "ceil(concat_1_childHeight/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "child__column_distance_x", + "orient": "bottom", + "grid": false, + "title": "distance (binned)", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(concat_1_childWidth/10)"}, + "zindex": 0 + }, + { + "scale": "child__column_distance_y", + "orient": "left", + "grid": false, + "title": "Count of Records", + "labelOverlap": true, + "tickCount": {"signal": "ceil(concat_1_childHeight/40)"}, + "zindex": 0 + } + ] + }, + { + "type": "group", + "name": "child__column_delay_group", + "style": "cell", + "encode": { + "update": { + "width": {"signal": "concat_1_childWidth"}, + "height": {"signal": "concat_1_childHeight"} + } + }, + "marks": [ + { + "name": "child__column_delay_layer_0_marks", + "type": "rect", + "style": ["bar"], + "interactive": false, + "from": {"data": "data_5"}, + "encode": { + "update": { + "fill": {"value": "steelblue"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"delay (binned): \" + (!isValid(datum[\"bin_maxbins_20_delay\"]) || !isFinite(+datum[\"bin_maxbins_20_delay\"]) ? \"null\" : format(datum[\"bin_maxbins_20_delay\"], \"\") + \" – \" + format(datum[\"bin_maxbins_20_delay_end\"], \"\")) + \"; Count of Records: \" + (format(datum[\"__count\"], \"\"))" + }, + "x2": { + "scale": "child__column_delay_x", + "field": "bin_maxbins_20_delay", + "offset": 1 + }, + "x": { + "scale": "child__column_delay_x", + "field": "bin_maxbins_20_delay_end" + }, + "y": {"scale": "child__column_delay_y", "field": "__count"}, + "y2": {"scale": "child__column_delay_y", "value": 0} + } + } + }, + { + "name": "child__column_delay_layer_1_marks", + "type": "rect", + "style": ["bar"], + "interactive": false, + "from": {"data": "data_2"}, + "encode": { + "update": { + "fill": {"value": "goldenrod"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"delay (binned): \" + (!isValid(datum[\"bin_maxbins_20_delay\"]) || !isFinite(+datum[\"bin_maxbins_20_delay\"]) ? \"null\" : format(datum[\"bin_maxbins_20_delay\"], \"\") + \" – \" + format(datum[\"bin_maxbins_20_delay_end\"], \"\")) + \"; Count of Records: \" + (format(datum[\"__count\"], \"\"))" + }, + "x2": { + "scale": "child__column_delay_x", + "field": "bin_maxbins_20_delay", + "offset": 1 + }, + "x": { + "scale": "child__column_delay_x", + "field": "bin_maxbins_20_delay_end" + }, + "y": {"scale": "child__column_delay_y", "field": "__count"}, + "y2": {"scale": "child__column_delay_y", "value": 0} + } + } + } + ], + "axes": [ + { + "scale": "child__column_delay_y", + "orient": "left", + "gridScale": "child__column_delay_x", + "grid": true, + "tickCount": {"signal": "ceil(concat_1_childHeight/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "child__column_delay_x", + "orient": "bottom", + "grid": false, + "title": "delay (binned)", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(concat_1_childWidth/10)"}, + "zindex": 0 + }, + { + "scale": "child__column_delay_y", + "orient": "left", + "grid": false, + "title": "Count of Records", + "labelOverlap": true, + "tickCount": {"signal": "ceil(concat_1_childHeight/40)"}, + "zindex": 0 + } + ] + } + ] + } + ], + "scales": [ + { + "name": "size", + "type": "linear", + "domain": {"data": "source_1", "field": "routes"}, + "range": [0, 500], + "zero": true + }, + { + "name": "child__column_distance_x", + "type": "linear", + "domain": { + "signal": "[child__column_distance_layer_0_bin_maxbins_20_distance_bins.start, child__column_distance_layer_0_bin_maxbins_20_distance_bins.stop]" + }, + "range": [0, {"signal": "concat_1_childWidth"}], + "bins": { + "signal": "child__column_distance_layer_0_bin_maxbins_20_distance_bins" + }, + "zero": false + }, + { + "name": "child__column_distance_y", + "type": "linear", + "domain": { + "fields": [ + {"data": "data_4", "field": "__count"}, + {"data": "data_3", "field": "__count"} + ] + }, + "range": [{"signal": "concat_1_childHeight"}, 0], + "nice": true, + "zero": true + }, + { + "name": "child__column_delay_x", + "type": "linear", + "domain": { + "signal": "[child__column_delay_layer_1_bin_maxbins_20_delay_bins.start, child__column_delay_layer_1_bin_maxbins_20_delay_bins.stop]" + }, + "range": [0, {"signal": "concat_1_childWidth"}], + "bins": { + "signal": "child__column_delay_layer_1_bin_maxbins_20_delay_bins" + }, + "zero": false + }, + { + "name": "child__column_delay_y", + "type": "linear", + "domain": { + "fields": [ + {"data": "data_5", "field": "__count"}, + {"data": "data_2", "field": "__count"} + ] + }, + "range": [{"signal": "concat_1_childHeight"}, 0], + "nice": true, + "zero": true + } + ] +} diff --git a/examples/compiled/interactive_global_development.vg.json b/examples/compiled/interactive_global_development.vg.json index eaa4821ab8..a03ebdf8ae 100644 --- a/examples/compiled/interactive_global_development.vg.json +++ b/examples/compiled/interactive_global_development.vg.json @@ -5,7 +5,7 @@ "padding": 5, "width": 800, "height": 500, - "style": "cell", + "style": ["view", "cell"], "data": [ { "name": "year_store", diff --git a/examples/compiled/layer_arc_label.vg.json b/examples/compiled/layer_arc_label.vg.json index 4605a56604..558ba6bfe2 100644 --- a/examples/compiled/layer_arc_label.vg.json +++ b/examples/compiled/layer_arc_label.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 200, "height": 200, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/layer_point_line_regression.vg.json b/examples/compiled/layer_point_line_regression.vg.json index 3821e0a01e..1343f87402 100644 --- a/examples/compiled/layer_point_line_regression.vg.json +++ b/examples/compiled/layer_point_line_regression.vg.json @@ -4,7 +4,7 @@ "padding": 5, "width": 200, "height": 200, - "style": "cell", + "style": ["cell", "view"], "data": [ {"name": "source_0", "url": "data/movies.json", "format": {"type": "json"}}, { diff --git a/examples/compiled/point_angle_windvector.vg.json b/examples/compiled/point_angle_windvector.vg.json index 176d380288..5719b2b98b 100644 --- a/examples/compiled/point_angle_windvector.vg.json +++ b/examples/compiled/point_angle_windvector.vg.json @@ -5,6 +5,7 @@ "padding": 5, "width": 615, "height": 560, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/point_overlap.vg.json b/examples/compiled/point_overlap.vg.json index 690003f29a..3097b82c0a 100644 --- a/examples/compiled/point_overlap.vg.json +++ b/examples/compiled/point_overlap.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 20, "height": 20, + "style": "view", "data": [ { "name": "source_0", diff --git a/examples/compiled/test_single_point_color.vg.json b/examples/compiled/test_single_point_color.vg.json index 45956b336d..daba568187 100644 --- a/examples/compiled/test_single_point_color.vg.json +++ b/examples/compiled/test_single_point_color.vg.json @@ -4,6 +4,7 @@ "padding": 5, "width": 20, "height": 20, + "style": "view", "data": [{"name": "source_0", "values": [{"a": 2}]}], "marks": [ { diff --git a/examples/specs/interactive_1d_geo_brush.vl.json b/examples/specs/interactive_1d_geo_brush.vl.json new file mode 100644 index 0000000000..d9fad3c115 --- /dev/null +++ b/examples/specs/interactive_1d_geo_brush.vl.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "width": 500, + "height": 300, + "projection": {"type": "albersUsa"}, + "layer": [ + { + "data": { + "url": "data/us-10m.json", + "format": {"type": "topojson", "feature": "states"} + }, + "mark": {"type": "geoshape", "fill": "lightgray", "stroke": "white"} + }, + { + "data": {"url": "data/airports.csv"}, + "transform": [ + {"filter": "datum.state !== 'PR' && datum.state !== 'VI'"} + ], + "params": [ + { + "name": "brush", + "select": {"type": "interval", "encodings": ["latitude"]}, + "value": {"latitude": [45, 51.5]} + } + ], + "mark": "circle", + "encoding": { + "longitude": {"field": "longitude", "type": "quantitative"}, + "latitude": {"field": "latitude", "type": "quantitative"}, + "color": { + "condition": {"param": "brush", "empty": false, "value": "goldenrod"}, + "value": "steelblue" + }, + "size": {"value": 10} + } + } + ] +} diff --git a/examples/specs/interactive_airport_crossfilter.vl.json b/examples/specs/interactive_airport_crossfilter.vl.json new file mode 100644 index 0000000000..fc2164b9d1 --- /dev/null +++ b/examples/specs/interactive_airport_crossfilter.vl.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "vconcat": [ + { + "width": 500, + "height": 300, + "projection": { + "type": "albersUsa" + }, + "layer": [ + { + "data": { + "url": "data/us-10m.json", + "format": { + "type": "topojson", + "feature": "states" + } + }, + "mark": { + "type": "geoshape", + "fill": "lightgray", + "stroke": "white" + } + }, + { + "data": {"url": "data/flights-airport.csv"}, + "transform": [ + {"aggregate": [{"op": "count", "as": "routes"}], "groupby": ["origin"]}, + { + "lookup": "origin", + "from": { + "data": {"url": "data/airports.csv"}, + "key": "iata", + "fields": ["state", "latitude", "longitude"] + } + }, + {"filter": "datum.state !== 'PR' && datum.state !== 'VI'"} + ], + "params": [ + { + "name": "brush", + "select": "interval", + "value": {"latitude": [30, 40], "longitude": [-86, -118]} + } + ], + "mark": "circle", + "encoding": { + "longitude": { + "field": "longitude", + "type": "quantitative" + }, + "latitude": { + "field": "latitude", + "type": "quantitative" + }, + "size": { + "field": "routes", + "type": "quantitative", + "scale": {"rangeMax": 500}, + "legend": null + }, + "color": { + "condition": { + "param": "brush", "empty": false, + "value": "goldenrod" + }, + "value": "steelblue"} + } + } + ] + }, + { + "data": { + "url": "data/flights-2k.json", + "format": {"parse": {"date": "date"}} + }, + "transform": [ + { + "lookup": "origin", + "from": {"param": "brush", "key": "origin"} + } + ], + "repeat": {"column": ["distance", "delay"]}, + "spec": { + "layer": [ + { + "mark": "bar", + "encoding": { + "x": { + "field": {"repeat": "column"}, + "bin": {"maxbins": 20} + }, + "y": {"aggregate": "count"}, + "color": {"value": "steelblue"} + } + }, + { + "transform": [{ + "filter": "data('brush_store').length && isValid(datum.brush)" + }], + "mark": "bar", + "encoding": { + "x": { + "field": {"repeat": "column"}, + "bin": {"maxbins": 20} + }, + "y": {"aggregate": "count"}, + "color": {"value": "goldenrod"} + } + } + ] + } + } + ] +} diff --git a/examples/specs/normalized/interactive_1d_geo_brush_normalized.vl.json b/examples/specs/normalized/interactive_1d_geo_brush_normalized.vl.json new file mode 100644 index 0000000000..fce30944c7 --- /dev/null +++ b/examples/specs/normalized/interactive_1d_geo_brush_normalized.vl.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "width": 500, + "height": 300, + "layer": [ + { + "data": { + "url": "data/us-10m.json", + "format": {"type": "topojson", "feature": "states"} + }, + "mark": {"type": "geoshape", "fill": "lightgray", "stroke": "white"}, + "projection": {"type": "albersUsa"} + }, + { + "data": {"url": "data/airports.csv"}, + "params": [ + { + "name": "brush", + "select": {"type": "interval", "encodings": ["latitude"]}, + "value": {"latitude": [45, 51.5]} + } + ], + "mark": "circle", + "encoding": { + "longitude": {"field": "longitude", "type": "quantitative"}, + "latitude": {"field": "latitude", "type": "quantitative"}, + "color": { + "condition": {"param": "brush", "empty": false, "value": "goldenrod"}, + "value": "steelblue" + }, + "size": {"value": 10} + }, + "transform": [{"filter": "datum.state !== 'PR' && datum.state !== 'VI'"}], + "projection": {"type": "albersUsa"} + } + ] +} \ No newline at end of file diff --git a/examples/specs/normalized/interactive_airport_crossfilter_normalized.vl.json b/examples/specs/normalized/interactive_airport_crossfilter_normalized.vl.json new file mode 100644 index 0000000000..796b0de451 --- /dev/null +++ b/examples/specs/normalized/interactive_airport_crossfilter_normalized.vl.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "vconcat": [ + { + "width": 500, + "height": 300, + "layer": [ + { + "data": { + "url": "data/us-10m.json", + "format": {"type": "topojson", "feature": "states"} + }, + "mark": {"type": "geoshape", "fill": "lightgray", "stroke": "white"}, + "projection": {"type": "albersUsa"} + }, + { + "data": {"url": "data/flights-airport.csv"}, + "params": [ + { + "name": "brush", + "select": "interval", + "value": {"latitude": [30, 40], "longitude": [-86, -118]} + } + ], + "mark": "circle", + "encoding": { + "longitude": {"field": "longitude", "type": "quantitative"}, + "latitude": {"field": "latitude", "type": "quantitative"}, + "size": { + "field": "routes", + "type": "quantitative", + "scale": {"rangeMax": 500}, + "legend": null + }, + "color": { + "condition": { + "param": "brush", + "empty": false, + "value": "goldenrod" + }, + "value": "steelblue" + } + }, + "transform": [ + { + "aggregate": [{"op": "count", "as": "routes"}], + "groupby": ["origin"] + }, + { + "lookup": "origin", + "from": { + "data": {"url": "data/airports.csv"}, + "key": "iata", + "fields": ["state", "latitude", "longitude"] + } + }, + {"filter": "datum.state !== 'PR' && datum.state !== 'VI'"} + ], + "projection": {"type": "albersUsa"} + } + ] + }, + { + "data": { + "url": "data/flights-2k.json", + "format": {"parse": {"date": "date"}} + }, + "align": "all", + "transform": [ + {"lookup": "origin", "from": {"param": "brush", "key": "origin"}} + ], + "columns": 2, + "concat": [ + { + "layer": [ + { + "mark": "bar", + "encoding": { + "x": {"field": "distance", "bin": {"maxbins": 20}}, + "y": {"aggregate": "count"}, + "color": {"value": "steelblue"} + } + }, + { + "mark": "bar", + "encoding": { + "x": {"field": "distance", "bin": {"maxbins": 20}}, + "y": {"aggregate": "count"}, + "color": {"value": "goldenrod"} + }, + "transform": [ + {"filter": "data('brush_store').length && isValid(datum.brush)"} + ] + } + ], + "name": "child__column_distance" + }, + { + "layer": [ + { + "mark": "bar", + "encoding": { + "x": {"field": "delay", "bin": {"maxbins": 20}}, + "y": {"aggregate": "count"}, + "color": {"value": "steelblue"} + } + }, + { + "mark": "bar", + "encoding": { + "x": {"field": "delay", "bin": {"maxbins": 20}}, + "y": {"aggregate": "count"}, + "color": {"value": "goldenrod"} + }, + "transform": [ + {"filter": "data('brush_store').length && isValid(datum.brush)"} + ] + } + ], + "name": "child__column_delay" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/compile/mark/mark.ts b/src/compile/mark/mark.ts index 119d32eb8c..009d974753 100644 --- a/src/compile/mark/mark.ts +++ b/src/compile/mark/mark.ts @@ -376,7 +376,7 @@ function interactiveFlag(model: UnitModel) { } return parentCount ? { - interactive: unitCount > 0 || !!model.encoding.tooltip + interactive: unitCount > 0 || model.mark === 'geoshape' || !!model.encoding.tooltip } : null; } diff --git a/src/compile/selection/assemble.ts b/src/compile/selection/assemble.ts index e3069c4795..1b12eb3ee3 100644 --- a/src/compile/selection/assemble.ts +++ b/src/compile/selection/assemble.ts @@ -5,7 +5,7 @@ import {MODIFY, STORE, unitName, VL_SELECTION_RESOLVE, TUPLE, selectionCompilers import {dateTimeToExpr, isDateTime, dateTimeToTimestamp} from '../../datetime'; import {hasContinuousDomain} from '../../scale'; import {SelectionInit, SelectionInitInterval, ParameterExtent, SELECTION_ID} from '../../selection'; -import {keys, stringify, vals} from '../../util'; +import {keys, replacePathInField, stringify, vals} from '../../util'; import {VgData, VgDomain} from '../../vega.schema'; import {FacetModel} from '../facet'; import {LayerModel} from '../layer'; @@ -13,6 +13,13 @@ import {isUnitModel, Model} from '../model'; import {ScaleComponent} from '../scale/component'; import {UnitModel} from '../unit'; import {parseSelectionExtent} from './parse'; +import {SelectionProjection} from './project'; + +export function assembleProjection(proj: SelectionProjection) { + const {signals, hasLegend, index, ...rest} = proj; + rest.field = replacePathInField(rest.field); + return rest; +} export function assembleInit( init: readonly (SelectionInit | readonly SelectionInit[] | SelectionInitInterval)[] | SelectionInit, @@ -124,10 +131,7 @@ export function assembleUnitSelectionData(model: UnitModel, data: readonly VgDat } if (selCmpt.init) { - const fields = selCmpt.project.items.map(proj => { - const {signals, ...rest} = proj; - return rest; - }); + const fields = selCmpt.project.items.map(assembleProjection); store.values = selCmpt.project.hasSelectionId ? selCmpt.init.map(i => ({unit, [SELECTION_ID]: assembleInit(i, false)[0]})) diff --git a/src/compile/selection/index.ts b/src/compile/selection/index.ts index 604e84a637..7c0045c245 100644 --- a/src/compile/selection/index.ts +++ b/src/compile/selection/index.ts @@ -1,4 +1,4 @@ -import {Binding, isString, NewSignal, Signal, Stream} from 'vega'; +import {Binding, isString, Signal, Stream} from 'vega'; import {stringValue} from 'vega-util'; import {FACET_CHANNELS} from '../../channel'; import { @@ -45,8 +45,6 @@ export interface SelectionComponent { bind?: 'scales' | Binding | Dict | LegendBinding; resolve: SelectionResolution; mark?: BrushConfig; - - // Transforms project: SelectionProjectionComponent; scales?: SelectionProjection[]; toggle?: string; @@ -59,8 +57,8 @@ export interface SelectionComponent { export interface SelectionCompiler { defined: (selCmpt: SelectionComponent) => boolean; parse?: (model: UnitModel, selCmpt: SelectionComponent, def: SelectionParameter) => void; - signals?: (model: UnitModel, selCmpt: SelectionComponent, signals: NewSignal[]) => Signal[]; // the output can be a new or a push signal - topLevelSignals?: (model: Model, selCmpt: SelectionComponent, signals: NewSignal[]) => NewSignal[]; + signals?: (model: UnitModel, selCmpt: SelectionComponent, signals: Signal[]) => Signal[]; + topLevelSignals?: (model: Model, selCmpt: SelectionComponent, signals: Signal[]) => Signal[]; modifyExpr?: (model: UnitModel, selCmpt: SelectionComponent, expr: string) => string; marks?: (model: UnitModel, selCmpt: SelectionComponent, marks: any[]) => any[]; } diff --git a/src/compile/selection/inputs.ts b/src/compile/selection/inputs.ts index a434dc24e7..8e8e6c3afd 100644 --- a/src/compile/selection/inputs.ts +++ b/src/compile/selection/inputs.ts @@ -6,6 +6,7 @@ import nearest from './nearest'; import {TUPLE_FIELDS} from './project'; import {SelectionCompiler} from '.'; import {isLegendBinding} from '../../selection'; +import {NewSignal} from 'vega'; const inputBindings: SelectionCompiler<'point'> = { defined: selCmpt => { @@ -54,7 +55,7 @@ const inputBindings: SelectionCompiler<'point'> = { signals: (model, selCmpt, signals) => { const name = selCmpt.name; const proj = selCmpt.project; - const signal = signals.filter(s => s.name === name + TUPLE)[0]; + const signal: NewSignal = signals.filter(s => s.name === name + TUPLE)[0]; const fields = name + TUPLE_FIELDS; const values = proj.items.map(p => varName(`${name}_${p.field}`)); const valid = values.map(v => `${v} !== null`).join(' && '); diff --git a/src/compile/selection/interval.ts b/src/compile/selection/interval.ts index a06b9c0ce2..cda65f2b39 100644 --- a/src/compile/selection/interval.ts +++ b/src/compile/selection/interval.ts @@ -1,11 +1,14 @@ -import {NewSignal, OnEvent, Stream} from 'vega'; +import {isObject, NewSignal, OnEvent, SignalValue, Stream} from 'vega'; import {array, stringValue} from 'vega-util'; import {SelectionCompiler, SelectionComponent, STORE, TUPLE, unitName} from '.'; -import {ScaleChannel, X, Y} from '../../channel'; +import {GeoPositionChannel, LATITUDE, LONGITUDE, ScaleChannel, X, Y} from '../../channel'; +import {FieldName} from '../../channeldef'; import {warn} from '../../log'; import {hasContinuousDomain} from '../../scale'; -import {SelectionInitInterval} from '../../selection'; -import {keys} from '../../util'; +import {IntervalSelectionConfigWithoutType, SelectionInitInterval, SELECTION_ID} from '../../selection'; +import {keys, vals} from '../../util'; +import {LayoutSizeIndex} from '../layoutsize/component'; +import {isUnitModel} from '../model'; import {UnitModel} from '../unit'; import {assembleInit} from './assemble'; import {SelectionProjection, TUPLE_FIELDS} from './project'; @@ -13,92 +16,183 @@ import scales from './scales'; export const BRUSH = '_brush'; export const SCALE_TRIGGER = '_scale_trigger'; +export const GEO_INIT_TICK = 'geo_interval_init_tick'; // Workaround for https://github.com/vega/vega/issues/3481 +const INIT = '_init'; +const CENTER = '_center'; + +// Separate type because the "fields" property is only used internally and we don't want to leak it to the schema. +export type IntervalSelectionConfigWithField = IntervalSelectionConfigWithoutType & {fields?: FieldName[]}; const interval: SelectionCompiler<'interval'> = { defined: selCmpt => selCmpt.type === 'interval', - signals: (model, selCmpt, signals) => { - const name = selCmpt.name; - const fieldsSg = name + TUPLE_FIELDS; - const hasScales = scales.defined(selCmpt); - const init = selCmpt.init ? selCmpt.init[0] : null; - const dataSignals: string[] = []; - const scaleTriggers: { - scaleName: string; - expr: string; - }[] = []; - - if (selCmpt.translate && !hasScales) { - const filterExpr = `!event.item || event.item.mark.name !== ${stringValue(name + BRUSH)}`; - events(selCmpt, (on: OnEvent[], evt: Stream) => { + parse: (model, selCmpt, selDef) => { + if (model.hasProjection) { + const def: IntervalSelectionConfigWithField = {...(isObject(selDef.select) ? selDef.select : {})}; + def.fields = [SELECTION_ID]; + if (!def.encodings) { + // Remap default x/y projection + def.encodings = selDef.value ? (keys(selDef.value) as GeoPositionChannel[]) : [LONGITUDE, LATITUDE]; + } + + selDef.select = {type: 'interval', ...def}; + } + + if (selCmpt.translate && !scales.defined(selCmpt)) { + const filterExpr = `!event.item || event.item.mark.name !== ${stringValue(selCmpt.name + BRUSH)}`; + for (const evt of selCmpt.events) { + if (!evt.between) { + warn(`${evt} is not an ordered event stream for interval selections.`); + continue; + } + const filters = array((evt.between[0].filter ??= [])); - if (!filters.includes(filterExpr)) { + if (filters.indexOf(filterExpr) < 0) { filters.push(filterExpr); } - return on; - }); + } } + }, + + signals: (model, selCmpt, signals) => { + const name = selCmpt.name; + const tupleSg = name + TUPLE; + const channels = vals(selCmpt.project.hasChannel).filter(p => p.channel === X || p.channel === Y); + const init = selCmpt.init ? selCmpt.init[0] : null; + + signals.push( + ...channels.reduce((arr, proj) => arr.concat(channelSignals(model, selCmpt, proj, init && init[proj.index])), []) + ); - selCmpt.project.items.forEach((proj, i) => { - const channel = proj.channel; - if (channel !== X && channel !== Y) { - warn('Interval selections only support x and y encoding channels.'); - return; + if (!model.hasProjection) { + // Proxy scale reactions to ensure that an infinite loop doesn't occur + // when an interval selection filter touches the scale. + if (!scales.defined(selCmpt)) { + const triggerSg = name + SCALE_TRIGGER; + const scaleTriggers = channels.map(proj => { + const channel = proj.channel as 'x' | 'y'; + const {data: dname, visual: vname} = proj.signals; + const scaleName = stringValue(model.scaleName(channel)); + const scaleType = model.getScaleComponent(channel).get('type'); + const toNum = hasContinuousDomain(scaleType) ? '+' : ''; + return ( + `(!isArray(${dname}) || ` + + `(${toNum}invert(${scaleName}, ${vname})[0] === ${toNum}${dname}[0] && ` + + `${toNum}invert(${scaleName}, ${vname})[1] === ${toNum}${dname}[1]))` + ); + }); + + if (scaleTriggers.length) { + signals.push({ + name: triggerSg, + value: {}, + on: [ + { + events: channels.map(proj => ({scale: model.scaleName(proj.channel)})), + update: scaleTriggers.join(' && ') + ` ? ${triggerSg} : {}` + } + ] + }); + } } - const val = init ? init[i] : null; - const cs = channelSignals(model, selCmpt, proj, val); - const dname = proj.signals.data; - const vname = proj.signals.visual; - const scaleName = stringValue(model.scaleName(channel)); - const scaleType = model.getScaleComponent(channel).get('type'); - const toNum = hasContinuousDomain(scaleType) ? '+' : ''; - - signals.push(...cs); - dataSignals.push(dname); - - scaleTriggers.push({ - scaleName: model.scaleName(channel), - expr: - `(!isArray(${dname}) || ` + - `(${toNum}invert(${scaleName}, ${vname})[0] === ${toNum}${dname}[0] && ` + - `${toNum}invert(${scaleName}, ${vname})[1] === ${toNum}${dname}[1]))` + // Only add an interval to the store if it has valid data extents. Data extents + // are set to null if pixel extents are equal to account for intervals over + // ordinal/nominal domains which, when inverted, will still produce a valid datum. + const dataSignals = channels.map(proj => proj.signals.data); + const update = `unit: ${unitName(model)}, fields: ${name + TUPLE_FIELDS}, values`; + return signals.concat({ + name: tupleSg, + ...(init ? {init: `{${update}: ${assembleInit(init)}}`} : {}), + ...(dataSignals.length + ? { + on: [ + { + events: [{signal: dataSignals.join(' || ')}], // Prevents double invocation, see https://github.com/vega/vega/issues/1672. + update: `${dataSignals.join(' && ')} ? {${update}: [${dataSignals}]} : null` + } + ] + } + : {}) }); - }); + } else { + const projection = stringValue(model.projectionName()); + const centerSg = model.projectionName() + CENTER; + const {x, y} = selCmpt.project.hasChannel; + const xvname = x && x.signals.visual; + const yvname = y && y.signals.visual; + const xinit = x ? init && init[x.index] : `${centerSg}[0]`; + const yinit = y ? init && init[y.index] : `${centerSg}[1]`; + const sizeSg = (layout: keyof LayoutSizeIndex) => model.getSizeSignalRef(layout).signal; + const bbox = + `[` + + `[${xvname ? xvname + '[0]' : '0'}, ${yvname ? yvname + '[0]' : '0'}],` + + `[${xvname ? xvname + '[1]' : sizeSg('width')}, ` + + `${yvname ? yvname + '[1]' : sizeSg('height')}]` + + `]`; + + if (init) { + signals.unshift({ + name: name + INIT, + init: + `[scale(${projection}, [${x ? xinit[0] : xinit}, ${y ? yinit[0] : yinit}]), ` + + `scale(${projection}, [${x ? xinit[1] : xinit}, ${y ? yinit[1] : yinit}])]` + }); - // Proxy scale reactions to ensure that an infinite loop doesn't occur - // when an interval selection filter touches the scale. - if (!hasScales && scaleTriggers.length) { - signals.push({ - name: name + SCALE_TRIGGER, - value: {}, + if (!x || !y) { + // If initializing a uni-dimensional brush, use the center of the view to determine the other coord + const hasCenterSg = signals.find(s => s.name === centerSg); + if (!hasCenterSg) { + signals.unshift({ + name: centerSg, + update: `invert(${projection}, [${sizeSg('width')}/2, ${sizeSg('height')}/2])` + }); + } + } + } + + const intersect = `intersect(${bbox}, {markname: ${stringValue(model.getName('marks'))}}, unit.mark)`; + const base = `{unit: ${unitName(model)}}`; + const update = `vlSelectionTuples(${intersect}, ${base})`; + const visualSignals = channels.map(proj => proj.signals.visual); + + return signals.concat({ + name: tupleSg, on: [ { - events: scaleTriggers.map(t => ({scale: t.scaleName})), - update: `${scaleTriggers.map(t => t.expr).join(' && ')} ? ${name + SCALE_TRIGGER} : {}` + events: [ + ...(visualSignals.length ? [{signal: visualSignals.join(' || ')}] : []), + ...(init ? [{signal: GEO_INIT_TICK}] : []) + ], + update } ] }); } + }, - // Only add an interval to the store if it has valid data extents. Data extents - // are set to null if pixel extents are equal to account for intervals over - // ordinal/nominal domains which, when inverted, will still produce a valid datum. - const update = `unit: ${unitName(model)}, fields: ${fieldsSg}, values`; - return signals.concat({ - name: name + TUPLE, - ...(init ? {init: `{${update}: ${assembleInit(init)}}`} : {}), - ...(dataSignals.length - ? { - on: [ - { - events: [{signal: dataSignals.join(' || ')}], // Prevents double invocation, see https://github.com/vega/vega#1672. - update: `${dataSignals.join(' && ')} ? {${update}: [${dataSignals}]} : null` - } - ] - } - : {}) - }); + topLevelSignals: (model, selCmpt, signals) => { + if (isUnitModel(model) && model.hasProjection && selCmpt.init) { + // Workaround for https://github.com/vega/vega/issues/3481 + // The scenegraph isn't populated on the first pulse. So we use a timer signal + // to re-pulse the dataflow as soon as possible. We return an object to ensure + // this only occurs once. + const hasTick = signals.filter(s => s.name === GEO_INIT_TICK); + if (!hasTick.length) { + signals.unshift({ + name: GEO_INIT_TICK, + value: null, + on: [ + { + events: 'timer{1}', + update: `${GEO_INIT_TICK} === null ? {} : ${GEO_INIT_TICK}` + } + ] + }); + } + } + + return signals; }, marks: (model, selCmpt, marks) => { @@ -192,62 +286,59 @@ function channelSignals( model: UnitModel, selCmpt: SelectionComponent<'interval'>, proj: SelectionProjection, - init?: SelectionInitInterval + init: SelectionInitInterval ): NewSignal[] { + const scaledInterval = !model.hasProjection; const channel = proj.channel; const vname = proj.signals.visual; - const dname = proj.signals.data; - const hasScales = scales.defined(selCmpt); - const scaleName = stringValue(model.scaleName(channel)); - const scale = model.getScaleComponent(channel as ScaleChannel); - const scaleType = scale ? scale.get('type') : undefined; + + const scaleName = stringValue(scaledInterval ? model.scaleName(channel) : model.projectionName()); const scaled = (str: string) => `scale(${scaleName}, ${str})`; + const size = model.getSizeSignalRef(channel === X ? 'width' : 'height').signal; const coord = `${channel}(unit)`; - - const on = events(selCmpt, (def: OnEvent[], evt: Stream) => { + const von = selCmpt.events.reduce((def: OnEvent[], evt: Stream) => { return [ ...def, {events: evt.between[0], update: `[${coord}, ${coord}]`}, // Brush Start {events: evt, update: `[${vname}[0], clamp(${coord}, 0, ${size})]`} // Brush End ]; - }); - - // React to pan/zooms of continuous scales. Non-continuous scales - // (band, point) cannot be pan/zoomed and any other changes - // to their domains (e.g., filtering) should clear the brushes. - on.push({ - events: {signal: selCmpt.name + SCALE_TRIGGER}, - update: hasContinuousDomain(scaleType) ? `[${scaled(`${dname}[0]`)}, ${scaled(`${dname}[1]`)}]` : `[0, 0]` - }); - - return hasScales - ? [{name: dname, on: []}] - : [ - { - name: vname, - ...(init ? {init: assembleInit(init, true, scaled)} : {value: []}), - on - }, - { - name: dname, - ...(init ? {init: assembleInit(init)} : {}), // Cannot be `value` as `init` may require datetime exprs. - on: [ - { - events: {signal: vname}, - update: `${vname}[0] === ${vname}[1] ? null : invert(${scaleName}, ${vname})` - } - ] - } - ]; -} + }, []); -function events(selCmpt: SelectionComponent<'interval'>, cb: (def: OnEvent[], evt: Stream) => OnEvent[]): OnEvent[] { - return selCmpt.events.reduce((on, evt) => { - if (!evt.between) { - warn(`${evt} is not an ordered event stream for interval selections.`); - return on; - } - return cb(on, evt); - }, [] as OnEvent[]); + if (scaledInterval) { + const dname = proj.signals.data; + const hasScales = scales.defined(selCmpt); + const scale = model.getScaleComponent(channel as ScaleChannel); + const scaleType = scale ? scale.get('type') : undefined; + const vinit: SignalValue = init ? {init: assembleInit(init, true, scaled)} : {value: []}; + + // React to pan/zooms of continuous scales. Non-continuous scales + // (band, point) cannot be pan/zoomed and any other changes + // to their domains (e.g., filtering) should clear the brushes. + von.push({ + events: {signal: selCmpt.name + SCALE_TRIGGER}, + update: hasContinuousDomain(scaleType) ? `[${scaled(`${dname}[0]`)}, ${scaled(`${dname}[1]`)}]` : `[0, 0]` + }); + + return hasScales + ? [{name: dname, on: []}] + : [ + {name: vname, ...vinit, on: von}, + { + name: dname, + ...(init ? {init: assembleInit(init)} : {}), // Cannot be `value` as `init` may require datetime exprs. + on: [ + { + events: {signal: vname}, + update: `${vname}[0] === ${vname}[1] ? null : invert(${scaleName}, ${vname})` + } + ] + } + ]; + } else { + const initIdx = channel === X ? 0 : 1; + const initSg = selCmpt.name + INIT; + const vinit: SignalValue = init ? {init: `[${initSg}[0][${initIdx}], ${initSg}[1][${initIdx}]]`} : {value: []}; + return [{name: vname, ...vinit, on: von}]; + } } diff --git a/src/compile/selection/legends.ts b/src/compile/selection/legends.ts index a649383b1c..ad9a51876e 100644 --- a/src/compile/selection/legends.ts +++ b/src/compile/selection/legends.ts @@ -1,4 +1,4 @@ -import {isObject, MergedStream, Stream} from 'vega'; +import {isObject, MergedStream, NewSignal, Stream} from 'vega'; import {parseSelector} from 'vega-event-selector'; import {array, isString} from 'vega-util'; import {disableDirectManipulation, TUPLE} from '.'; @@ -85,7 +85,7 @@ const legendBindings: SelectionCompiler<'point'> = { signals: (model, selCmpt, signals) => { const name = selCmpt.name; const proj = selCmpt.project; - const tuple = signals.find(s => s.name === name + TUPLE); + const tuple: NewSignal = signals.find(s => s.name === name + TUPLE); const fields = name + TUPLE_FIELDS; const values = proj.items.filter(p => p.hasLegend).map(p => varName(`${name}_${varName(p.field)}_legend`)); const valid = values.map(v => `${v} !== null`).join(' && '); diff --git a/src/compile/selection/parse.ts b/src/compile/selection/parse.ts index 554ef8fa36..d816af1505 100644 --- a/src/compile/selection/parse.ts +++ b/src/compile/selection/parse.ts @@ -39,7 +39,7 @@ export function parseUnitSelection(model: UnitModel, selDefs: SelectionParameter } if (defaults[key] === undefined || defaults[key] === true) { - defaults[key] = cfg[key] ?? defaults[key]; + defaults[key] = duplicate(cfg[key] ?? defaults[key]); } } @@ -52,9 +52,10 @@ export function parseUnitSelection(model: UnitModel, selDefs: SelectionParameter events: isString(defaults.on) ? parseSelector(defaults.on, 'scope') : array(duplicate(defaults.on)) } as any); + const def_ = duplicate(def); // defensive copy to prevent compilers from causing side effects for (const c of selectionCompilers) { if (c.defined(selCmpt) && c.parse) { - c.parse(model, selCmpt, def); + c.parse(model, selCmpt, def_); } } } diff --git a/src/compile/selection/project.ts b/src/compile/selection/project.ts index 706e75ea16..9cfd1ece1c 100644 --- a/src/compile/selection/project.ts +++ b/src/compile/selection/project.ts @@ -1,11 +1,19 @@ import {array, isObject} from 'vega-util'; -import {isSingleDefUnitChannel, ScaleChannel, SingleDefUnitChannel} from '../../channel'; +import { + GeoPositionChannel, + getPositionChannelFromLatLong, + isGeoPositionChannel, + isScaleChannel, + isSingleDefUnitChannel, + SingleDefUnitChannel +} from '../../channel'; import * as log from '../../log'; import {hasContinuousDomain} from '../../scale'; import {PointSelectionConfig, SelectionInitIntervalMapping, SelectionInitMapping, SELECTION_ID} from '../../selection'; -import {Dict, hash, keys, replacePathInField, varName, isEmpty} from '../../util'; +import {Dict, hash, keys, varName, isEmpty} from '../../util'; import {TimeUnitComponent, TimeUnitNode} from '../data/timeunit'; import {SelectionCompiler} from '.'; +import {assembleProjection} from './assemble'; export const TUPLE_FIELDS = '_tuple_fields'; /** @@ -22,7 +30,9 @@ export type TupleStoreType = export interface SelectionProjection { type: TupleStoreType; field: string; + index: number; channel?: SingleDefUnitChannel; + geoChannel?: GeoPositionChannel; signals?: {data?: string; visual?: string}; hasLegend?: boolean; } @@ -86,10 +96,10 @@ const project: SelectionCompiler = { (encodings || (encodings = [])).push(key as SingleDefUnitChannel); } else { if (type === 'interval') { - log.warn(log.message.INTERVAL_INITIALIZED_WITH_X_Y); + log.warn(log.message.INTERVAL_INITIALIZED_WITH_POS); encodings = cfg.encodings; } else { - (fields || (fields = [])).push(key); + (fields ??= []).push(key); } } } @@ -140,21 +150,28 @@ const project: SelectionCompiler = { // Determine whether the tuple will store enumerated or ranged values. // Interval selections store ranges for continuous scales, and enumerations otherwise. // Single/multi selections store ranges for binned fields, and enumerations otherwise. - let tplType: TupleStoreType = 'E'; - if (type === 'interval') { - const scaleType = model.getScaleComponent(channel as ScaleChannel).get('type'); - if (hasContinuousDomain(scaleType)) { - tplType = 'R'; - } - } else if (fieldDef.bin) { - tplType = 'R-RE'; - } - - const p: SelectionProjection = {field, channel, type: tplType}; + const tplType: TupleStoreType = + type === 'interval' && + isScaleChannel(channel) && + hasContinuousDomain(model.getScaleComponent(channel).get('type')) + ? 'R' + : fieldDef.bin + ? 'R-RE' + : 'E'; + + const p: SelectionProjection = {field, channel, type: tplType, index: proj.items.length}; p.signals = {...signalName(p, 'data'), ...signalName(p, 'visual')}; proj.items.push((parsed[field] = p)); - proj.hasField[field] = proj.hasChannel[channel] = parsed[field]; + proj.hasField[field] = parsed[field]; proj.hasSelectionId = proj.hasSelectionId || field === SELECTION_ID; + + if (isGeoPositionChannel(channel)) { + p.geoChannel = channel; + p.channel = getPositionChannelFromLatLong(channel); + proj.hasChannel[p.channel] = parsed[field]; + } else { + proj.hasChannel[channel] = parsed[field]; + } } } else { log.warn(log.message.cannotProjectOnChannelWithoutField(channel)); @@ -163,7 +180,7 @@ const project: SelectionCompiler = { for (const field of fields ?? []) { if (proj.hasField[field]) continue; - const p: SelectionProjection = {type: 'E', field}; + const p: SelectionProjection = {type: 'E', field, index: proj.items.length}; p.signals = {...signalName(p, 'data')}; proj.items.push(p); proj.hasField[field] = p; @@ -174,7 +191,9 @@ const project: SelectionCompiler = { selCmpt.init = (init as any).map((v: SelectionInitMapping | SelectionInitIntervalMapping) => { // Selections can be initialized either with a full object that maps projections to values // or scalar values to smoothen the abstraction gradient from variable params to point selections. - return proj.items.map(p => (isObject(v) ? (v[p.channel] !== undefined ? v[p.channel] : v[p.field]) : v)); + return proj.items.map(p => + isObject(v) ? (v[p.geoChannel || p.channel] !== undefined ? v[p.geoChannel || p.channel] : v[p.field]) : v + ); }); } @@ -190,11 +209,7 @@ const project: SelectionCompiler = { ? allSignals : allSignals.concat({ name, - value: selCmpt.project.items.map(proj => { - const {signals, hasLegend, ...rest} = proj; - rest.field = replacePathInField(rest.field); - return rest; - }) + value: selCmpt.project.items.map(assembleProjection) }); } }; diff --git a/src/compile/selection/scales.ts b/src/compile/selection/scales.ts index d05ebccda6..d716bfa20e 100644 --- a/src/compile/selection/scales.ts +++ b/src/compile/selection/scales.ts @@ -8,6 +8,7 @@ import {UnitModel} from '../unit'; import {SelectionProjection} from './project'; import {SelectionCompiler} from '.'; import {replacePathInField} from '../../util'; +import {NewSignal} from 'vega'; const scaleBindings: SelectionCompiler<'interval'> = { defined: selCmpt => { @@ -52,7 +53,7 @@ const scaleBindings: SelectionCompiler<'interval'> = { // state is captured by the top-level signals that we insert and "push // outer" to from within the units. We need to reassemble this state into // the top-level named signal, except no single selCmpt has a global view. - const namedSg = signals.filter(s => s.name === selCmpt.name)[0]; + const namedSg: NewSignal = signals.filter(s => s.name === selCmpt.name)[0]; let update = namedSg.update; if (update.indexOf(VL_SELECTION_RESOLVE) >= 0) { namedSg.update = `{${bound diff --git a/src/compile/selection/translate.ts b/src/compile/selection/translate.ts index f412e5fab5..0c2602c6e6 100644 --- a/src/compile/selection/translate.ts +++ b/src/compile/selection/translate.ts @@ -18,12 +18,12 @@ const translate: SelectionCompiler<'interval'> = { signals: (model, selCmpt, signals) => { const name = selCmpt.name; - const hasScales = scalesCompiler.defined(selCmpt); + const boundScales = scalesCompiler.defined(selCmpt); const anchor = name + ANCHOR; const {x, y} = selCmpt.project.hasChannel; let events = parseSelector(selCmpt.translate, 'scope'); - if (!hasScales) { + if (!boundScales) { events = events.map(e => ((e.between[0].markname = name + INTERVAL_BRUSH), e)); } @@ -36,8 +36,8 @@ const translate: SelectionCompiler<'interval'> = { events: events.map(e => e.between[0]), update: '{x: x(unit), y: y(unit)' + - (x !== undefined ? `, extent_x: ${hasScales ? domain(model, X) : `slice(${x.signals.visual})`}` : '') + - (y !== undefined ? `, extent_y: ${hasScales ? domain(model, Y) : `slice(${y.signals.visual})`}` : '') + + (x !== undefined ? `, extent_x: ${boundScales ? domain(model, X) : `slice(${x.signals.visual})`}` : '') + + (y !== undefined ? `, extent_y: ${boundScales ? domain(model, Y) : `slice(${y.signals.visual})`}` : '') + '}' } ] @@ -79,25 +79,26 @@ function onDelta( const anchor = name + ANCHOR; const delta = name + DELTA; const channel = proj.channel as ScaleChannel; - const hasScales = scalesCompiler.defined(selCmpt); - const signal = signals.filter(s => s.name === proj.signals[hasScales ? 'data' : 'visual'])[0]; + const boundScales = scalesCompiler.defined(selCmpt); + const signal = signals.filter(s => s.name === proj.signals[boundScales ? 'data' : 'visual'])[0]; const sizeSg = model.getSizeSignalRef(size).signal; const scaleCmpt = model.getScaleComponent(channel); - const scaleType = scaleCmpt.get('type'); - const reversed = scaleCmpt.get('reverse'); // scale parsing sets this flag for fieldDef.sort - const sign = !hasScales ? '' : channel === X ? (reversed ? '' : '-') : reversed ? '-' : ''; + const scaleType = scaleCmpt && scaleCmpt.get('type'); + const reversed = scaleCmpt && scaleCmpt.get('reverse'); // scale parsing sets this flag for fieldDef.sort + const sign = !boundScales ? '' : channel === X ? (reversed ? '' : '-') : reversed ? '-' : ''; const extent = `${anchor}.extent_${channel}`; - const offset = `${sign}${delta}.${channel} / ${hasScales ? `${sizeSg}` : `span(${extent})`}`; - const panFn = !hasScales - ? 'panLinear' - : scaleType === 'log' - ? 'panLog' - : scaleType === 'symlog' - ? 'panSymlog' - : scaleType === 'pow' - ? 'panPow' - : 'panLinear'; - const arg = !hasScales + const offset = `${sign}${delta}.${channel} / ${boundScales ? `${sizeSg}` : `span(${extent})`}`; + const panFn = + !boundScales || !scaleCmpt + ? 'panLinear' + : scaleType === 'log' + ? 'panLog' + : scaleType === 'symlog' + ? 'panSymlog' + : scaleType === 'pow' + ? 'panPow' + : 'panLinear'; + const arg = !boundScales ? '' : scaleType === 'pow' ? `, ${scaleCmpt.get('exponent') ?? 1}` @@ -108,6 +109,6 @@ function onDelta( signal.on.push({ events: {signal: delta}, - update: hasScales ? update : `clampRange(${update}, 0, ${sizeSg})` + update: boundScales ? update : `clampRange(${update}, 0, ${sizeSg})` }); } diff --git a/src/compile/selection/zoom.ts b/src/compile/selection/zoom.ts index accdfc3077..3b3e6ce2ec 100644 --- a/src/compile/selection/zoom.ts +++ b/src/compile/selection/zoom.ts @@ -19,14 +19,14 @@ const zoom: SelectionCompiler<'interval'> = { signals: (model, selCmpt, signals) => { const name = selCmpt.name; - const hasScales = scalesCompiler.defined(selCmpt); + const boundScales = scalesCompiler.defined(selCmpt); const delta = name + DELTA; const {x, y} = selCmpt.project.hasChannel; const sx = stringValue(model.scaleName(X)); const sy = stringValue(model.scaleName(Y)); let events = parseSelector(selCmpt.zoom, 'scope'); - if (!hasScales) { + if (!boundScales) { events = events.map(e => ((e.markname = name + INTERVAL_BRUSH), e)); } @@ -36,7 +36,7 @@ const zoom: SelectionCompiler<'interval'> = { on: [ { events, - update: !hasScales + update: !boundScales ? `{x: x(unit), y: y(unit)}` : '{' + [sx ? `x: invert(${sx}, x(unit))` : '', sy ? `y: invert(${sy}, y(unit))` : ''] @@ -81,24 +81,25 @@ function onDelta( ) { const name = selCmpt.name; const channel = proj.channel as ScaleChannel; - const hasScales = scalesCompiler.defined(selCmpt); - const signal = signals.filter(s => s.name === proj.signals[hasScales ? 'data' : 'visual'])[0]; + const boundScales = scalesCompiler.defined(selCmpt); + const signal = signals.filter(s => s.name === proj.signals[boundScales ? 'data' : 'visual'])[0]; const sizeSg = model.getSizeSignalRef(size).signal; const scaleCmpt = model.getScaleComponent(channel); - const scaleType = scaleCmpt.get('type'); - const base = hasScales ? domain(model, channel) : signal.name; + const scaleType = scaleCmpt && scaleCmpt.get('type'); + const base = boundScales ? domain(model, channel) : signal.name; const delta = name + DELTA; const anchor = `${name}${ANCHOR}.${channel}`; - const zoomFn = !hasScales - ? 'zoomLinear' - : scaleType === 'log' - ? 'zoomLog' - : scaleType === 'symlog' - ? 'zoomSymlog' - : scaleType === 'pow' - ? 'zoomPow' - : 'zoomLinear'; - const arg = !hasScales + const zoomFn = + !boundScales || !scaleCmpt + ? 'zoomLinear' + : scaleType === 'log' + ? 'zoomLog' + : scaleType === 'symlog' + ? 'zoomSymlog' + : scaleType === 'pow' + ? 'zoomPow' + : 'zoomLinear'; + const arg = !boundScales ? '' : scaleType === 'pow' ? `, ${scaleCmpt.get('exponent') ?? 1}` @@ -109,6 +110,6 @@ function onDelta( signal.on.push({ events: {signal: delta}, - update: hasScales ? update : `clampRange(${update}, 0, ${sizeSg})` + update: boundScales ? update : `clampRange(${update}, 0, ${sizeSg})` }); } diff --git a/src/compile/unit.ts b/src/compile/unit.ts index 6ccf1384fc..c13ebde5b3 100644 --- a/src/compile/unit.ts +++ b/src/compile/unit.ts @@ -281,7 +281,7 @@ export class UnitModel extends ModelWithField { if (this.encoding.x || this.encoding.y) { return 'cell'; } else { - return undefined; + return 'view'; } } diff --git a/src/log/message.ts b/src/log/message.ts index c64a20c44d..adb059e2fe 100644 --- a/src/log/message.ts +++ b/src/log/message.ts @@ -97,7 +97,8 @@ export function noSameUnitLookup(name: string) { export const NEEDS_SAME_SELECTION = 'The same selection must be used to override scale domains in a layered view.'; -export const INTERVAL_INITIALIZED_WITH_X_Y = 'Interval selections should be initialized using "x" and/or "y" keys.'; +export const INTERVAL_INITIALIZED_WITH_POS = + 'Interval selections should be initialized using "x", "y", "longitude", or "latitude" keys.'; // REPEAT export function noSuchRepeatedValue(field: string) { diff --git a/test-runtime/interval.test.ts b/test-runtime/interval.test.ts index f16ff1a48c..1ece7a2dd2 100644 --- a/test-runtime/interval.test.ts +++ b/test-runtime/interval.test.ts @@ -1,6 +1,6 @@ import {TopLevelSpec} from '../src'; import {SelectionType} from '../src/selection'; -import {brush, embedFn, hits as hitsMaster, spec, testRenderFn, tuples} from './util'; +import {brush, embedFn, geoSpec, hits as hitsMaster, spec, testRenderFn, tuples} from './util'; import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; describe('interval selections at runtime in unit views', () => { @@ -197,4 +197,26 @@ describe('interval selections at runtime in unit views', () => { await testRender(`logpow_${i}`); } }); + + describe('geo-intervals', () => { + it('should add IDs to the store', async () => { + await embed(geoSpec()); + const store = await page.evaluate(brush('drag', 1)); + expect(store).toHaveLength(13); + for (const t of store) { + expect(t).toHaveProperty('_vgsid_'); + } + await testRender(`geo_1`); + }); + + it('should respect projections', async () => { + await embed(geoSpec({encodings: ['longitude']})); + const store = await page.evaluate(brush('drag', 0)); + expect(store).toHaveLength(20); + for (const t of store) { + expect(t).toHaveProperty('_vgsid_'); + } + await testRender(`geo_0`); + }); + }); }); diff --git a/test-runtime/resources/interval/translate/geo-0.svg b/test-runtime/resources/interval/translate/geo-0.svg new file mode 100644 index 0000000000..520738812d --- /dev/null +++ b/test-runtime/resources/interval/translate/geo-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/translate/geo-1.svg b/test-runtime/resources/interval/translate/geo-1.svg new file mode 100644 index 0000000000..3d5cea9393 --- /dev/null +++ b/test-runtime/resources/interval/translate/geo-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/translate/geo-2.svg b/test-runtime/resources/interval/translate/geo-2.svg new file mode 100644 index 0000000000..ec05c730b5 --- /dev/null +++ b/test-runtime/resources/interval/translate/geo-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/unit/geo_0.svg b/test-runtime/resources/interval/unit/geo_0.svg new file mode 100644 index 0000000000..c135608c7b --- /dev/null +++ b/test-runtime/resources/interval/unit/geo_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/unit/geo_1.svg b/test-runtime/resources/interval/unit/geo_1.svg new file mode 100644 index 0000000000..520738812d --- /dev/null +++ b/test-runtime/resources/interval/unit/geo_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/zoom/geo-0.svg b/test-runtime/resources/interval/zoom/geo-0.svg new file mode 100644 index 0000000000..520738812d --- /dev/null +++ b/test-runtime/resources/interval/zoom/geo-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/zoom/geo-1.svg b/test-runtime/resources/interval/zoom/geo-1.svg new file mode 100644 index 0000000000..2a8697a217 --- /dev/null +++ b/test-runtime/resources/interval/zoom/geo-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/resources/interval/zoom/geo-2.svg b/test-runtime/resources/interval/zoom/geo-2.svg new file mode 100644 index 0000000000..bba75f4357 --- /dev/null +++ b/test-runtime/resources/interval/zoom/geo-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-runtime/translate.test.ts b/test-runtime/translate.test.ts index 2f944c8fd3..ba0c7f3cd9 100644 --- a/test-runtime/translate.test.ts +++ b/test-runtime/translate.test.ts @@ -6,6 +6,7 @@ import { brush, compositeTypes, embedFn, + geoSpec, hits as hitsMaster, parentSelector, spec, @@ -16,117 +17,97 @@ import { import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; import {TopLevelSpec} from '../src'; -for (const bind of [bound, unbound]) { - describe(`Translate ${bind} interval selections at runtime`, () => { - 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, `interval/translate/${bind}`); - await page.goto('http://0.0.0.0:8000/test-runtime/'); - }); +describe('Translate interval selections at runtime', () => { + let page: Page; + let embed: (specification: TopLevelSpec) => Promise; + let testRender: (filename: string) => Promise; - afterAll(async () => { - await page.close(); - }); + beforeAll(async () => { + page = await (global as any).__BROWSER__.newPage(); + embed = embedFn(page); + await page.goto('http://0.0.0.0:8000/test-runtime/'); + }); - const type = 'interval'; - const hits = hitsMaster.interval; - const binding = bind === bound ? {bind: 'scales'} : {}; - - const assertExtent = { - [unbound]: { - x: ['isAbove', 'isBelow'], - y: ['isBelow', 'isAbove'] - }, - [bound]: { - x: ['isBelow', 'isAbove'], - y: ['isAbove', 'isBelow'] - } - }; - - it('should move back-and-forth', async () => { - for (let i = 0; i < hits.translate.length; i++) { - await embed(spec('unit', i, {type, ...binding})); - const drag = (await page.evaluate(brush('drag', i)))[0]; - await testRender(`${i}-0`); - const translate = (await page.evaluate(brush('translate', i, null, bind === unbound)))[0]; - assert[assertExtent[bind].x[i]](translate.values[0][0], drag.values[0][0]); - assert[assertExtent[bind].x[i]](translate.values[0][1], drag.values[0][1]); - assert[assertExtent[bind].y[i]](translate.values[1][0], drag.values[1][0]); - assert[assertExtent[bind].y[i]](translate.values[1][1], drag.values[1][1]); - await testRender(`${i}-1`); - } - }); + afterAll(async () => { + await page.close(); + }); - it('should work with binned domains', async () => { - for (let i = 0; i < hits.bins.length; i++) { - await embed( - spec( - 'unit', - 1, - {type, ...binding, encodings: ['y']}, - { - x: {aggregate: 'count', type: 'quantitative'}, - y: {bin: true}, - color: {value: 'steelblue', field: null, type: null} - } - ) - ); - const drag = (await page.evaluate(brush('bins', i)))[0]; - await testRender(`bins_${i}-0`); - const translate = (await page.evaluate(brush('bins_translate', i, null, bind === unbound)))[0]; - assert[assertExtent[bind].y[i]](translate.values[0][0], drag.values[0][0]); - assert[assertExtent[bind].y[i]](translate.values[0][1], drag.values[0][1]); - await testRender(`bins_${i}-1`); - } - }); + const hits = hitsMaster.interval; - it('should work with temporal domains', async () => { - // await jestPuppeteer.debug(); - const values = tuples.map(d => ({...d, a: new Date(2017, d.a)})); - const toNumber = (a: any) => a[0].values[0].map((d: any) => +d); - - for (let i = 0; i < hits.translate.length; i++) { - await embed(spec('unit', i, {type, ...binding, encodings: ['x']}, {values, x: {type: 'temporal'}})); - const drag = toNumber(await page.evaluate(brush('drag', i))); - await testRender(`temporal_${i}-0`); - const translate = toNumber(await page.evaluate(brush('translate', i, null, bind === unbound))); - assert[assertExtent[bind].x[i]](translate[0], drag[0]); - assert[assertExtent[bind].x[i]](translate[1], drag[1]); - await testRender(`temporal_${i}-1`); - } - }); + for (const bind of [bound, unbound]) { + describe(`${bind} intervals`, () => { + beforeAll(() => { + testRender = testRenderFn(page, `interval/translate/${bind}`); + }); - it('should work with log/pow scales', async () => { - for (let i = 0; i < hits.translate.length; i++) { - await embed( - spec( - 'unit', - i, - {type, ...binding}, - { - x: {scale: {type: 'pow', exponent: 1.5}}, - y: {scale: {type: 'log'}} - } - ) - ); - const drag = (await page.evaluate(brush('drag', i)))[0]; - await testRender(`logpow_${i}-0`); - const translate = (await page.evaluate(brush('translate', i, null, bind === unbound)))[0]; - assert[assertExtent[bind].x[i]](translate.values[0][0], drag.values[0][0]); - assert[assertExtent[bind].x[i]](translate.values[0][1], drag.values[0][1]); - assert[assertExtent[bind].y[i]](translate.values[1][0], drag.values[1][0]); - assert[assertExtent[bind].y[i]](translate.values[1][1], drag.values[1][1]); - await testRender(`logpow_${i}-1`); - } - }); + const type = 'interval'; + const binding = bind === bound ? {bind: 'scales'} : {}; - if (bind === unbound) { - it('should work with ordinal/nominal domains', async () => { + const assertExtent = { + [unbound]: { + x: ['isAbove', 'isBelow'], + y: ['isBelow', 'isAbove'] + }, + [bound]: { + x: ['isBelow', 'isAbove'], + y: ['isAbove', 'isBelow'] + } + }; + + it('should move back-and-forth', async () => { + for (let i = 0; i < hits.translate.length; i++) { + await embed(spec('unit', i, {type, ...binding})); + const drag = (await page.evaluate(brush('drag', i)))[0]; + await testRender(`${i}-0`); + const translate = (await page.evaluate(brush('translate', i, null, bind === unbound)))[0]; + assert[assertExtent[bind].x[i]](translate.values[0][0], drag.values[0][0]); + assert[assertExtent[bind].x[i]](translate.values[0][1], drag.values[0][1]); + assert[assertExtent[bind].y[i]](translate.values[1][0], drag.values[1][0]); + assert[assertExtent[bind].y[i]](translate.values[1][1], drag.values[1][1]); + await testRender(`${i}-1`); + } + }); + + it('should work with binned domains', async () => { + for (let i = 0; i < hits.bins.length; i++) { + await embed( + spec( + 'unit', + 1, + {type, ...binding, encodings: ['y']}, + { + x: {aggregate: 'count', type: 'quantitative'}, + y: {bin: true}, + color: {value: 'steelblue', field: null, type: null} + } + ) + ); + const drag = (await page.evaluate(brush('bins', i)))[0]; + await testRender(`bins_${i}-0`); + const translate = (await page.evaluate(brush('bins_translate', i, null, bind === unbound)))[0]; + assert[assertExtent[bind].y[i]](translate.values[0][0], drag.values[0][0]); + assert[assertExtent[bind].y[i]](translate.values[0][1], drag.values[0][1]); + await testRender(`bins_${i}-1`); + } + }); + + it('should work with temporal domains', async () => { + // await jestPuppeteer.debug(); + const values = tuples.map(d => ({...d, a: new Date(2017, d.a)})); + const toNumber = (a: any) => a[0].values[0].map((d: any) => +d); + + for (let i = 0; i < hits.translate.length; i++) { + await embed(spec('unit', i, {type, ...binding, encodings: ['x']}, {values, x: {type: 'temporal'}})); + const drag = toNumber(await page.evaluate(brush('drag', i))); + await testRender(`temporal_${i}-0`); + const translate = toNumber(await page.evaluate(brush('translate', i, null, bind === unbound))); + assert[assertExtent[bind].x[i]](translate[0], drag[0]); + assert[assertExtent[bind].x[i]](translate[1], drag[1]); + await testRender(`temporal_${i}-1`); + } + }); + + it('should work with log/pow scales', async () => { for (let i = 0; i < hits.translate.length; i++) { await embed( spec( @@ -134,48 +115,89 @@ for (const bind of [bound, unbound]) { i, {type, ...binding}, { - x: {type: 'ordinal'}, - y: {type: 'nominal'} + x: {scale: {type: 'pow', exponent: 1.5}}, + y: {scale: {type: 'log'}} } ) ); const drag = (await page.evaluate(brush('drag', i)))[0]; - await testRender(`ord_${i}-0`); - const translate = (await page.evaluate(brush('translate', i, null, true)))[0]; + await testRender(`logpow_${i}-0`); + const translate = (await page.evaluate(brush('translate', i, null, bind === unbound)))[0]; assert[assertExtent[bind].x[i]](translate.values[0][0], drag.values[0][0]); assert[assertExtent[bind].x[i]](translate.values[0][1], drag.values[0][1]); assert[assertExtent[bind].y[i]](translate.values[1][0], drag.values[1][0]); assert[assertExtent[bind].y[i]](translate.values[1][1], drag.values[1][1]); - await testRender(`ord_${i}-1`); + await testRender(`logpow_${i}-1`); } }); - } else { - for (const specType of compositeTypes) { - const assertExtents = { - repeat: { - x: ['isBelow', 'isBelow', 'isBelow'], - y: ['isAbove', 'isAbove', 'isAbove'] - }, - facet: { - x: ['isBelow', 'isBelow', 'isBelow'], - y: ['isBelow', 'isAbove', 'isBelow'] - } - }; - it(`should work with shared scales in ${specType} views`, async () => { - for (let i = 0; i < hits[specType].length; i++) { - await embed(spec(specType, 0, {type, ...binding}, {resolve: {scale: {x: 'shared', y: 'shared'}}})); - const parent = parentSelector(specType, i); - const xscale = await page.evaluate('view._runtime.scales.x.value.domain()'); - const yscale = await page.evaluate('view._runtime.scales.y.value.domain()'); - const drag = (await page.evaluate(brush(specType, i, parent)))[0]; - assert[assertExtents[specType].x[i]](drag.values[0][0], xscale[0], `iter: ${i}`); - assert[assertExtents[specType].x[i]](drag.values[0][1], xscale[1], `iter: ${i}`); - assert[assertExtents[specType].y[i]](drag.values[1][0], yscale[0], `iter: ${i}`); - assert[assertExtents[specType].y[i]](drag.values[1][1], yscale[1], `iter: ${i}`); - await testRender(`${specType}_${i}`); + + if (bind === unbound) { + it('should work with ordinal/nominal domains', async () => { + for (let i = 0; i < hits.translate.length; i++) { + await embed( + spec( + 'unit', + i, + {type, ...binding}, + { + x: {type: 'ordinal'}, + y: {type: 'nominal'} + } + ) + ); + const drag = (await page.evaluate(brush('drag', i)))[0]; + await testRender(`ord_${i}-0`); + const translate = (await page.evaluate(brush('translate', i, null, true)))[0]; + assert[assertExtent[bind].x[i]](translate.values[0][0], drag.values[0][0]); + assert[assertExtent[bind].x[i]](translate.values[0][1], drag.values[0][1]); + assert[assertExtent[bind].y[i]](translate.values[1][0], drag.values[1][0]); + assert[assertExtent[bind].y[i]](translate.values[1][1], drag.values[1][1]); + await testRender(`ord_${i}-1`); } }); + } else { + for (const specType of compositeTypes) { + const assertExtents = { + repeat: { + x: ['isBelow', 'isBelow', 'isBelow'], + y: ['isAbove', 'isAbove', 'isAbove'] + }, + facet: { + x: ['isBelow', 'isBelow', 'isBelow'], + y: ['isBelow', 'isAbove', 'isBelow'] + } + }; + it(`should work with shared scales in ${specType} views`, async () => { + for (let i = 0; i < hits[specType].length; i++) { + await embed(spec(specType, 0, {type, ...binding}, {resolve: {scale: {x: 'shared', y: 'shared'}}})); + const parent = parentSelector(specType, i); + const xscale = await page.evaluate('view._runtime.scales.x.value.domain()'); + const yscale = await page.evaluate('view._runtime.scales.y.value.domain()'); + const drag = (await page.evaluate(brush(specType, i, parent)))[0]; + assert[assertExtents[specType].x[i]](drag.values[0][0], xscale[0], `iter: ${i}`); + assert[assertExtents[specType].x[i]](drag.values[0][1], xscale[1], `iter: ${i}`); + assert[assertExtents[specType].y[i]](drag.values[1][0], yscale[0], `iter: ${i}`); + assert[assertExtents[specType].y[i]](drag.values[1][1], yscale[1], `iter: ${i}`); + await testRender(`${specType}_${i}`); + } + }); + } } + }); + } + + it('should work with geo intervals', async () => { + testRender = testRenderFn(page, `interval/translate`); + + await embed(geoSpec()); + const drag = await page.evaluate(brush('drag', 1)); + expect(drag).toHaveLength(13); + await testRender(`geo-0`); + + for (let i = 0; i < hits.translate.length; i++) { + const translate = await page.evaluate(brush('translate', i, null, true)); + expect(translate.length).toBeGreaterThan(0); + await testRender(`geo-${i + 1}`); } }); -} +}); diff --git a/test-runtime/util.ts b/test-runtime/util.ts index 192974f3a5..62c30db31a 100644 --- a/test-runtime/util.ts +++ b/test-runtime/util.ts @@ -3,7 +3,7 @@ import {sync as mkdirp} from 'mkdirp'; import {Page} from 'puppeteer/lib/cjs/puppeteer/common/Page'; import {promisify} from 'util'; import {stringValue} from 'vega-util'; -import {SelectionResolution, SelectionType} from '../src/selection'; +import {IntervalSelectionConfigWithoutType, SelectionResolution, SelectionType} from '../src/selection'; import {NormalizedLayerSpec, NormalizedUnitSpec, TopLevelSpec} from '../src/spec'; const generate = process.env.VL_GENERATE_TESTS; @@ -107,6 +107,14 @@ export const hits = { } }; +const config = { + // reduce changes in generated SVGs + aria: false, + + // A lot of magic numbers in this file use the old step = 21 + view: {discreteWidth: {step: 21}, discreteHeight: {step: 21}} +}; + function base(iter: number, selDef: any, opts: any = {}): NormalizedUnitSpec | NormalizedLayerSpec { const data = {values: opts.values ?? tuples}; const x = {field: 'a', type: 'quantitative', ...opts.x}; @@ -160,13 +168,6 @@ function base(iter: number, selDef: any, opts: any = {}): NormalizedUnitSpec | N export function spec(compose: ComposeType, iter: number, sel: any, opts: any = {}): TopLevelSpec { const {data, ...specification} = base(iter, sel, opts); const resolve = opts.resolve; - const config = { - // reduce changes in generated SVGs - aria: false, - - // A lot of magic numbers in this file use the old step = 21 - view: {discreteWidth: {step: 21}, discreteHeight: {step: 21}} - }; switch (compose) { case 'unit': return {data, ...specification, config} as TopLevelSpec; @@ -189,6 +190,65 @@ export function spec(compose: ComposeType, iter: number, sel: any, opts: any = { } } +export function geoSpec(selDef?: IntervalSelectionConfigWithoutType): TopLevelSpec { + return { + width: 500, + height: 300, + projection: {type: 'albersUsa'}, + config, + data: { + values: [ + {latitude: 31.95376472, longitude: -89.23450472}, + {latitude: 30.68586111, longitude: -95.01792778}, + {latitude: 38.94574889, longitude: -104.5698933}, + {latitude: 42.74134667, longitude: -78.05208056}, + {latitude: 30.6880125, longitude: -81.90594389}, + {latitude: 34.49166667, longitude: -88.20111111}, + {latitude: 32.85048667, longitude: -86.61145333}, + {latitude: 43.08751, longitude: -88.17786917}, + {latitude: 40.67331278, longitude: -80.64140639}, + {latitude: 40.44725889, longitude: -92.22696056}, + {latitude: 33.93011222, longitude: -89.34285194}, + {latitude: 46.88384889, longitude: -96.35089861}, + {latitude: 41.51961917, longitude: -87.40109333}, + {latitude: 31.42127556, longitude: -97.79696778}, + {latitude: 39.60416667, longitude: -116.0050597}, + {latitude: 32.46047167, longitude: -85.68003611}, + {latitude: 41.98934083, longitude: -88.10124278}, + {latitude: 48.88434111, longitude: -99.62087694}, + {latitude: 33.53456583, longitude: -89.31256917}, + {latitude: 41.43156583, longitude: -74.39191722}, + {latitude: 41.97602222, longitude: -114.6580911}, + {latitude: 41.30716667, longitude: -85.06433333}, + {latitude: 32.52883861, longitude: -94.97174556}, + {latitude: 42.57450861, longitude: -84.81143139}, + {latitude: 41.11668056, longitude: -98.05033639}, + {latitude: 32.52943944, longitude: -86.32822139}, + {latitude: 48.30079861, longitude: -102.4063514}, + {latitude: 40.65138528, longitude: -98.07978667}, + {latitude: 32.76124611, longitude: -89.53007139}, + {latitude: 32.11931306, longitude: -88.1274625} + ] + }, + mark: 'circle', + params: [ + { + name: 'sel', + select: {type: 'interval', ...selDef} + } + ], + encoding: { + longitude: {field: 'longitude', type: 'quantitative'}, + latitude: {field: 'latitude', type: 'quantitative'}, + color: { + condition: {param: 'sel', empty: false, value: 'goldenrod'}, + value: 'steelblue' + }, + size: {value: 10} + } + }; +} + export function unitNameRegex(specType: ComposeType, idx: number) { const name = UNIT_NAMES[specType][idx].replace('child_', ''); return new RegExp(`child(.*?)_${name}`); diff --git a/test-runtime/zoom.test.ts b/test-runtime/zoom.test.ts index 4ae6169ef4..f823ec3ba0 100644 --- a/test-runtime/zoom.test.ts +++ b/test-runtime/zoom.test.ts @@ -1,7 +1,18 @@ /* eslint-disable jest/expect-expect */ import {assert} from 'chai'; -import {bound, brush, compositeTypes, embedFn, parentSelector, spec, testRenderFn, tuples, unbound} from './util'; +import { + bound, + brush, + compositeTypes, + embedFn, + geoSpec, + parentSelector, + spec, + testRenderFn, + tuples, + unbound +} from './util'; const hits = { zoom: [9, 23], bins: [8, 2] @@ -16,140 +27,115 @@ function zoom(key: string, idx: number, direction: InOut, parent?: string, targe return `zoom(${hits[key][idx]}, ${delta}, ${parent}, ${targetBrush})`; } -const cmp = (a: number, b: number) => a - b; +describe('Zoom interval selections at runtime', () => { + let page: Page; + let embed: (specification: TopLevelSpec) => Promise; + let testRender: (filename: string) => Promise; -for (const bind of [bound, unbound]) { - describe(`Zoom ${bind} interval selections at runtime`, () => { - 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, `interval/zoom/${bind}`); - await page.goto('http://0.0.0.0:8000/test-runtime/'); - }); - - afterAll(async () => { - await page.close(); - }); + beforeAll(async () => { + page = await (global as any).__BROWSER__.newPage(); + embed = embedFn(page); + await page.goto('http://0.0.0.0:8000/test-runtime/'); + }); - const type = 'interval'; - const binding = bind === bound ? {bind: 'scales'} : {}; + afterAll(async () => { + await page.close(); + }); - const assertExtent = { - in: ['isAtLeast', 'isAtMost'], - out: ['isAtMost', 'isAtLeast'] - }; + for (const bind of [bound, unbound]) { + describe(`Zoom ${bind} interval selections at runtime`, () => { + beforeAll(() => { + testRender = testRenderFn(page, `interval/zoom/${bind}`); + }); - async function setup(brushKey: string, idx: number, encodings: string[], parent?: string) { - const inOut: InOut = idx % 2 ? 'out' : 'in'; - let xold: number[]; - let yold: number[]; + const type = 'interval'; + const binding = bind === bound ? {bind: 'scales'} : {}; + const cmp = (a: number, b: number) => a - b; + + const assertExtent = { + in: ['isAtLeast', 'isAtMost'], + out: ['isAtMost', 'isAtLeast'] + }; + + async function setup(brushKey: string, idx: number, encodings: string[], parent?: string) { + const inOut: InOut = idx % 2 ? 'out' : 'in'; + let xold: number[]; + let yold: number[]; + + if (bind === unbound) { + const drag = (await page.evaluate(brush(brushKey, idx, parent)))[0]; + xold = drag.values[0].sort(cmp); + yold = encodings.includes('y') ? drag.values[encodings.indexOf('x') + 1].sort(cmp) : null; + } else { + xold = JSON.parse((await page.evaluate('JSON.stringify(view._runtime.scales.x.value.domain())')) as string); + yold = (await page.evaluate('view._runtime.scales.y.value.domain()')) as number[]; + } - if (bind === unbound) { - const drag = (await page.evaluate(brush(brushKey, idx, parent)))[0]; - xold = drag.values[0].sort(cmp); - yold = encodings.includes('y') ? drag.values[encodings.indexOf('x') + 1].sort(cmp) : null; - } else { - xold = JSON.parse((await page.evaluate('JSON.stringify(view._runtime.scales.x.value.domain())')) as string); - yold = (await page.evaluate('view._runtime.scales.y.value.domain()')) as number[]; + return {inOut, xold, yold}; } - return {inOut, xold, yold}; - } + it('should zoom in and out', async () => { + for (let i = 0; i < hits.zoom.length; i++) { + await embed(spec('unit', i, {type, ...binding})); + const {inOut, xold, yold} = await setup('drag', i, ['x', 'y']); + await testRender(`${inOut}-0`); - it('should zoom in and out', async () => { - for (let i = 0; i < hits.zoom.length; i++) { - await embed(spec('unit', i, {type, ...binding})); - const {inOut, xold, yold} = await setup('drag', i, ['x', 'y']); - await testRender(`${inOut}-0`); - - const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; - const xnew = zoomed.values[0].sort(cmp); - const ynew = zoomed.values[1].sort(cmp); - await testRender(`${inOut}-1`); - assert[assertExtent[inOut][0]](xnew[0], xold[0]); - assert[assertExtent[inOut][1]](xnew[1], xold[1]); - assert[assertExtent[inOut][0]](ynew[0], yold[0]); - assert[assertExtent[inOut][1]](ynew[1], yold[1]); - } - }); + const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; + const xnew = zoomed.values[0].sort(cmp); + const ynew = zoomed.values[1].sort(cmp); + await testRender(`${inOut}-1`); + assert[assertExtent[inOut][0]](xnew[0], xold[0]); + assert[assertExtent[inOut][1]](xnew[1], xold[1]); + assert[assertExtent[inOut][0]](ynew[0], yold[0]); + assert[assertExtent[inOut][1]](ynew[1], yold[1]); + } + }); - it('should work with binned domains', async () => { - for (let i = 0; i < hits.bins.length; i++) { - const encodings = ['y']; - await embed( - spec( - 'unit', - 1, - {type, ...binding, encodings}, - { - x: {aggregate: 'count', type: 'quantitative'}, - y: {bin: true}, - color: {value: 'steelblue', field: null, type: null} - } - ) - ); + it('should work with binned domains', async () => { + for (let i = 0; i < hits.bins.length; i++) { + const encodings = ['y']; + await embed( + spec( + 'unit', + 1, + {type, ...binding, encodings}, + { + x: {aggregate: 'count', type: 'quantitative'}, + y: {bin: true}, + color: {value: 'steelblue', field: null, type: null} + } + ) + ); - const {inOut, yold} = await setup('bins', i, encodings); - await testRender(`bins_${inOut}-0`); + const {inOut, yold} = await setup('bins', i, encodings); + await testRender(`bins_${inOut}-0`); - const zoomed = (await page.evaluate(zoom('bins', i, inOut, null, bind === unbound)))[0]; - const ynew = zoomed.values[0].sort(cmp); - assert[assertExtent[inOut][0]](ynew[0], yold[0]); - assert[assertExtent[inOut][1]](ynew[1], yold[1]); - await testRender(`bins_${inOut}-1`); - } - }); - - it('should work with temporal domains', async () => { - const values = tuples.map(d => ({...d, a: new Date(2017, d.a)})); - const encodings = ['x']; + const zoomed = (await page.evaluate(zoom('bins', i, inOut, null, bind === unbound)))[0]; + const ynew = zoomed.values[0].sort(cmp); + assert[assertExtent[inOut][0]](ynew[0], yold[0]); + assert[assertExtent[inOut][1]](ynew[1], yold[1]); + await testRender(`bins_${inOut}-1`); + } + }); - for (let i = 0; i < hits.zoom.length; i++) { - await embed(spec('unit', i, {type, ...binding, encodings}, {values, x: {type: 'temporal'}})); - const {inOut, xold} = await setup('drag', i, encodings); - await testRender(`temporal_${inOut}-0`); + it('should work with temporal domains', async () => { + const values = tuples.map(d => ({...d, a: new Date(2017, d.a)})); + const encodings = ['x']; - const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; - const xnew = zoomed.values[0].sort(cmp); - assert[assertExtent[inOut][0]](+xnew[0], +new Date(xold[0])); - assert[assertExtent[inOut][1]](+xnew[1], +new Date(xold[1])); - await testRender(`temporal_${inOut}-1`); - } - }); + for (let i = 0; i < hits.zoom.length; i++) { + await embed(spec('unit', i, {type, ...binding, encodings}, {values, x: {type: 'temporal'}})); + const {inOut, xold} = await setup('drag', i, encodings); + await testRender(`temporal_${inOut}-0`); - it('should work with log/pow scales', async () => { - for (let i = 0; i < hits.zoom.length; i++) { - await embed( - spec( - 'unit', - i, - {type, ...binding}, - { - x: {scale: {type: 'pow', exponent: 1.5}}, - y: {scale: {type: 'log'}} - } - ) - ); - const {inOut, xold, yold} = await setup('drag', i, ['x', 'y']); - await testRender(`logpow_${inOut}-0`); - - const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; - const xnew = zoomed.values[0].sort(cmp); - const ynew = zoomed.values[1].sort(cmp); - assert[assertExtent[inOut][0]](xnew[0], xold[0]); - assert[assertExtent[inOut][1]](xnew[1], xold[1]); - assert[assertExtent[inOut][0]](ynew[0], yold[0]); - assert[assertExtent[inOut][1]](ynew[1], yold[1]); - await testRender(`logpow_${inOut}-1`); - } - }); + const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; + const xnew = zoomed.values[0].sort(cmp); + assert[assertExtent[inOut][0]](+xnew[0], +new Date(xold[0])); + assert[assertExtent[inOut][1]](+xnew[1], +new Date(xold[1])); + await testRender(`temporal_${inOut}-1`); + } + }); - if (bind === unbound) { - it('should work with ordinal/nominal domains', async () => { + it('should work with log/pow scales', async () => { for (let i = 0; i < hits.zoom.length; i++) { await embed( spec( @@ -157,47 +143,91 @@ for (const bind of [bound, unbound]) { i, {type, ...binding}, { - x: {type: 'ordinal'}, - y: {type: 'nominal'} + x: {scale: {type: 'pow', exponent: 1.5}}, + y: {scale: {type: 'log'}} } ) ); const {inOut, xold, yold} = await setup('drag', i, ['x', 'y']); - await testRender(`ord_${inOut}-0`); + await testRender(`logpow_${inOut}-0`); const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; const xnew = zoomed.values[0].sort(cmp); const ynew = zoomed.values[1].sort(cmp); - - if (inOut === 'in') { - expect(xnew.length).toBeLessThanOrEqual(xold.length); - expect(ynew.length).toBeLessThanOrEqual(yold.length); - } else { - expect(xnew.length).toBeGreaterThanOrEqual(xold.length); - expect(ynew.length).toBeGreaterThanOrEqual(yold.length); - } - - await testRender(`ord_${inOut}-1`); + assert[assertExtent[inOut][0]](xnew[0], xold[0]); + assert[assertExtent[inOut][1]](xnew[1], xold[1]); + assert[assertExtent[inOut][0]](ynew[0], yold[0]); + assert[assertExtent[inOut][1]](ynew[1], yold[1]); + await testRender(`logpow_${inOut}-1`); } }); - } else { - for (const specType of compositeTypes) { - it(`should work with shared scales in ${specType} views`, async () => { - for (let i = 0; i < hits.bins.length; i++) { - await embed(spec(specType, 0, {type, ...binding}, {resolve: {scale: {x: 'shared', y: 'shared'}}})); - const parent = parentSelector(specType, i); - const {inOut, xold, yold} = await setup(specType, i, ['x', 'y'], parent); - const zoomed = (await page.evaluate(zoom('bins', i, inOut, null, bind === unbound)))[0]; + + if (bind === unbound) { + it('should work with ordinal/nominal domains', async () => { + for (let i = 0; i < hits.zoom.length; i++) { + await embed( + spec( + 'unit', + i, + {type, ...binding}, + { + x: {type: 'ordinal'}, + y: {type: 'nominal'} + } + ) + ); + const {inOut, xold, yold} = await setup('drag', i, ['x', 'y']); + await testRender(`ord_${inOut}-0`); + + const zoomed = (await page.evaluate(zoom('zoom', i, inOut, null, bind === unbound)))[0]; const xnew = zoomed.values[0].sort(cmp); const ynew = zoomed.values[1].sort(cmp); - assert[assertExtent[inOut][0]](xnew[0], xold[0]); - assert[assertExtent[inOut][1]](xnew[1], xold[1]); - assert[assertExtent[inOut][0]](ynew[0], yold[0]); - assert[assertExtent[inOut][1]](ynew[1], yold[1]); - await testRender(`${specType}_${inOut}`); + + if (inOut === 'in') { + expect(xnew.length).toBeLessThanOrEqual(xold.length); + expect(ynew.length).toBeLessThanOrEqual(yold.length); + } else { + expect(xnew.length).toBeGreaterThanOrEqual(xold.length); + expect(ynew.length).toBeGreaterThanOrEqual(yold.length); + } + + await testRender(`ord_${inOut}-1`); } }); + } else { + for (const specType of compositeTypes) { + it(`should work with shared scales in ${specType} views`, async () => { + for (let i = 0; i < hits.bins.length; i++) { + await embed(spec(specType, 0, {type, ...binding}, {resolve: {scale: {x: 'shared', y: 'shared'}}})); + const parent = parentSelector(specType, i); + const {inOut, xold, yold} = await setup(specType, i, ['x', 'y'], parent); + const zoomed = (await page.evaluate(zoom('bins', i, inOut, null, bind === unbound)))[0]; + const xnew = zoomed.values[0].sort(cmp); + const ynew = zoomed.values[1].sort(cmp); + assert[assertExtent[inOut][0]](xnew[0], xold[0]); + assert[assertExtent[inOut][1]](xnew[1], xold[1]); + assert[assertExtent[inOut][0]](ynew[0], yold[0]); + assert[assertExtent[inOut][1]](ynew[1], yold[1]); + await testRender(`${specType}_${inOut}`); + } + }); + } } + }); + } + + it('should work with geo intervals', async () => { + testRender = testRenderFn(page, `interval/zoom`); + + await embed(geoSpec()); + const drag = await page.evaluate(brush('drag', 1)); + expect(drag).toHaveLength(13); + await testRender(`geo-0`); + + for (let i = 0; i < hits.zoom.length; i++) { + const zoomed = await page.evaluate(zoom('zoom', i, i % 2 ? 'out' : 'in', null, true)); + expect(zoomed.length).toBeGreaterThan(0); + await testRender(`geo-${i + 1}`); } }); -} +}); diff --git a/test/compile/model.test.ts b/test/compile/model.test.ts index 1f919833d3..7389cb1915 100644 --- a/test/compile/model.test.ts +++ b/test/compile/model.test.ts @@ -32,7 +32,7 @@ describe('Model', () => { mark: 'point' }); - expect(model.assembleGroupStyle()).toBeUndefined(); + expect(model.assembleGroupStyle()).toBe('view'); }); it('returns cell by default for cartesian plots', () => { diff --git a/test/compile/selection/interval.test.ts b/test/compile/selection/interval.test.ts index 1b8841a882..c626cd0784 100644 --- a/test/compile/selection/interval.test.ts +++ b/test/compile/selection/interval.test.ts @@ -1,391 +1,517 @@ import {parseSelector} from 'vega-event-selector'; import {assembleUnitSelectionSignals} from '../../../src/compile/selection/assemble'; -import interval from '../../../src/compile/selection/interval'; +import interval, {GEO_INIT_TICK} from '../../../src/compile/selection/interval'; import {parseUnitSelection} from '../../../src/compile/selection/parse'; -import {parseUnitModel} from '../../util'; +import {parseUnitModel, parseUnitModelWithScale, parseUnitModelWithScaleAndLayoutSize} from '../../util'; describe('Interval Selections', () => { - const model = parseUnitModel({ - mark: 'circle', - encoding: { - x: {field: 'Horsepower', type: 'quantitative'}, - y: {field: 'Miles-per-Gallon', type: 'quantitative'}, - color: {field: 'Origin', type: 'nominal'} - } - }); - model.parseScale(); - - const selCmpts = (model.component.selection = parseUnitSelection(model, [ - { - name: 'one', - select: {type: 'interval', encodings: ['x'], clear: false, translate: false, zoom: false} - }, - { - name: 'two', - select: { - type: 'interval', - encodings: ['y'], - clear: false, - translate: false, - zoom: false + describe('Scaled intervals', () => { + const model = parseUnitModelWithScale({ + mark: 'circle', + encoding: { + x: {field: 'Horsepower', type: 'quantitative'}, + y: {field: 'Miles-per-Gallon', type: 'quantitative'}, + color: {field: 'Origin', type: 'nominal'} + } + }); + + const selCmpts = (model.component.selection = parseUnitSelection(model, [ + { + name: 'one', + select: {type: 'interval', encodings: ['x'], clear: false, translate: false, zoom: false} }, - bind: 'scales' - }, - { - name: 'thr-ee', - select: { - type: 'interval', - on: '[mousedown, mouseup] > mousemove, [keydown, keyup] > keypress', - clear: false, - translate: false, - zoom: false, - resolve: 'intersect', - mark: { - fill: 'red', - fillOpacity: 0.75, - stroke: 'black', - strokeWidth: 4, - strokeDash: [10, 5], - strokeDashOffset: 3, - strokeOpacity: 0.25 + { + name: 'two', + select: { + type: 'interval', + encodings: ['y'], + clear: false, + translate: false, + zoom: false + }, + bind: 'scales' + }, + { + name: 'thr-ee', + select: { + type: 'interval', + on: '[mousedown, mouseup] > mousemove, [keydown, keyup] > keypress', + clear: false, + translate: false, + zoom: false, + resolve: 'intersect', + mark: { + fill: 'red', + fillOpacity: 0.75, + stroke: 'black', + strokeWidth: 4, + strokeDash: [10, 5], + strokeDashOffset: 3, + strokeOpacity: 0.25 + } } - } - }, - { - name: 'four', - value: {x: [50, 70]}, - select: { - type: 'interval', - encodings: ['x'], - clear: false, - translate: false, - zoom: false - } - }, - { - name: 'five', - value: {x: [50, 60], y: [23, 54]}, - select: { - type: 'interval', - clear: false, - translate: false, - zoom: false - } - }, - { - name: 'six', - value: { - x: [ - {year: 2000, month: 10, date: 5}, - {year: 2001, month: 1, date: 13} - ] }, - select: { - type: 'interval', - clear: false, - translate: false, - zoom: false, - encodings: ['x'] + { + name: 'four', + value: {x: [50, 70]}, + select: { + type: 'interval', + encodings: ['x'], + clear: false, + translate: false, + zoom: false + } + }, + { + name: 'five', + value: {x: [50, 60], y: [23, 54]}, + select: { + type: 'interval', + clear: false, + translate: false, + zoom: false + } + }, + { + name: 'six', + value: { + x: [ + {year: 2000, month: 10, date: 5}, + {year: 2001, month: 1, date: 13} + ] + }, + select: { + type: 'interval', + clear: false, + translate: false, + zoom: false, + encodings: ['x'] + } } - } - ])); + ])); - describe('Tuple Signals', () => { - it('builds projection signals', () => { - const oneSg = interval.signals(model, selCmpts['one'], []); - expect(oneSg).toEqual( - expect.arrayContaining([ - { - name: 'one_x', - value: [], - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], - update: '[one_x[0], clamp(x(unit), 0, width)]' - }, - { - events: {signal: 'one_scale_trigger'}, - update: '[scale("x", one_Horsepower[0]), scale("x", one_Horsepower[1])]' - } - ] - }, - { - name: 'one_Horsepower', - on: [ - { - events: {signal: 'one_x'}, - update: 'one_x[0] === one_x[1] ? null : invert("x", one_x)' - } - ] - }, - { - name: 'one_scale_trigger', - value: {}, - on: [ - { - events: [{scale: 'x'}], - update: - '(!isArray(one_Horsepower) || (+invert("x", one_x)[0] === +one_Horsepower[0] && +invert("x", one_x)[1] === +one_Horsepower[1])) ? one_scale_trigger : {}' - } - ] - } - ]) - ); + describe('Tuple Signals', () => { + it('builds projection signals', () => { + const oneSg = interval.signals(model, selCmpts['one'], []); + expect(oneSg).toEqual( + expect.arrayContaining([ + { + name: 'one_x', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[one_x[0], clamp(x(unit), 0, width)]' + }, + { + events: {signal: 'one_scale_trigger'}, + update: '[scale("x", one_Horsepower[0]), scale("x", one_Horsepower[1])]' + } + ] + }, + { + name: 'one_Horsepower', + on: [ + { + events: {signal: 'one_x'}, + update: 'one_x[0] === one_x[1] ? null : invert("x", one_x)' + } + ] + }, + { + name: 'one_scale_trigger', + value: {}, + on: [ + { + events: [{scale: 'x'}], + update: + '(!isArray(one_Horsepower) || (+invert("x", one_x)[0] === +one_Horsepower[0] && +invert("x", one_x)[1] === +one_Horsepower[1])) ? one_scale_trigger : {}' + } + ] + } + ]) + ); + + const twoSg = interval.signals(model, selCmpts['two'], []); + expect(twoSg).toContainEqual({ + name: 'two_Miles_per_Gallon', + on: [] + }); + + const threeSg = interval.signals(model, selCmpts['thr_ee'], []); + expect(threeSg).toEqual( + expect.arrayContaining([ + { + name: 'thr_ee_x', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, mouseup] > mousemove', 'scope')[0], + update: '[thr_ee_x[0], clamp(x(unit), 0, width)]' + }, + { + events: parseSelector('keydown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[keydown, keyup] > keypress', 'scope')[0], + update: '[thr_ee_x[0], clamp(x(unit), 0, width)]' + }, + { + events: {signal: 'thr_ee_scale_trigger'}, + update: '[scale("x", thr_ee_Horsepower[0]), scale("x", thr_ee_Horsepower[1])]' + } + ] + }, + { + name: 'thr_ee_Horsepower', + on: [ + { + events: {signal: 'thr_ee_x'}, + update: 'thr_ee_x[0] === thr_ee_x[1] ? null : invert("x", thr_ee_x)' + } + ] + }, + { + name: 'thr_ee_y', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[mousedown, mouseup] > mousemove', 'scope')[0], + update: '[thr_ee_y[0], clamp(y(unit), 0, height)]' + }, + { + events: parseSelector('keydown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[keydown, keyup] > keypress', 'scope')[0], + update: '[thr_ee_y[0], clamp(y(unit), 0, height)]' + }, + { + events: {signal: 'thr_ee_scale_trigger'}, + update: '[scale("y", thr_ee_Miles_per_Gallon[0]), scale("y", thr_ee_Miles_per_Gallon[1])]' + } + ] + }, + { + name: 'thr_ee_Miles_per_Gallon', + on: [ + { + events: {signal: 'thr_ee_y'}, + update: 'thr_ee_y[0] === thr_ee_y[1] ? null : invert("y", thr_ee_y)' + } + ] + }, + { + name: 'thr_ee_scale_trigger', + value: {}, + on: [ + { + events: [{scale: 'x'}, {scale: 'y'}], + update: + '(!isArray(thr_ee_Horsepower) || (+invert("x", thr_ee_x)[0] === +thr_ee_Horsepower[0] && +invert("x", thr_ee_x)[1] === +thr_ee_Horsepower[1])) && (!isArray(thr_ee_Miles_per_Gallon) || (+invert("y", thr_ee_y)[0] === +thr_ee_Miles_per_Gallon[0] && +invert("y", thr_ee_y)[1] === +thr_ee_Miles_per_Gallon[1])) ? thr_ee_scale_trigger : {}' + } + ] + } + ]) + ); - const twoSg = interval.signals(model, selCmpts['two'], []); - expect(twoSg).toContainEqual({ - name: 'two_Miles_per_Gallon', - on: [] + const fourSg = interval.signals(model, selCmpts['four'], []); + expect(fourSg).toEqual( + expect.arrayContaining([ + { + name: 'four_x', + init: '[scale("x", 50), scale("x", 70)]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[four_x[0], clamp(x(unit), 0, width)]' + }, + { + events: {signal: 'four_scale_trigger'}, + update: '[scale("x", four_Horsepower[0]), scale("x", four_Horsepower[1])]' + } + ] + }, + { + name: 'four_Horsepower', + init: '[50, 70]', + on: [ + { + events: {signal: 'four_x'}, + update: 'four_x[0] === four_x[1] ? null : invert("x", four_x)' + } + ] + }, + { + name: 'four_scale_trigger', + value: {}, + on: [ + { + events: [{scale: 'x'}], + update: + '(!isArray(four_Horsepower) || (+invert("x", four_x)[0] === +four_Horsepower[0] && +invert("x", four_x)[1] === +four_Horsepower[1])) ? four_scale_trigger : {}' + } + ] + } + ]) + ); + + const fiveSg = interval.signals(model, selCmpts['five'], []); + expect(fiveSg).toEqual( + expect.arrayContaining([ + { + name: 'five_x', + init: '[scale("x", 50), scale("x", 60)]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[five_x[0], clamp(x(unit), 0, width)]' + }, + { + events: {signal: 'five_scale_trigger'}, + update: '[scale("x", five_Horsepower[0]), scale("x", five_Horsepower[1])]' + } + ] + }, + { + name: 'five_Horsepower', + init: '[50, 60]', + on: [ + { + events: {signal: 'five_x'}, + update: 'five_x[0] === five_x[1] ? null : invert("x", five_x)' + } + ] + }, + { + name: 'five_y', + init: '[scale("y", 23), scale("y", 54)]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[five_y[0], clamp(y(unit), 0, height)]' + }, + { + events: {signal: 'five_scale_trigger'}, + update: '[scale("y", five_Miles_per_Gallon[0]), scale("y", five_Miles_per_Gallon[1])]' + } + ] + }, + { + name: 'five_Miles_per_Gallon', + init: '[23, 54]', + on: [ + { + events: {signal: 'five_y'}, + update: 'five_y[0] === five_y[1] ? null : invert("y", five_y)' + } + ] + }, + { + name: 'five_scale_trigger', + value: {}, + on: [ + { + events: [{scale: 'x'}, {scale: 'y'}], + update: + '(!isArray(five_Horsepower) || (+invert("x", five_x)[0] === +five_Horsepower[0] && +invert("x", five_x)[1] === +five_Horsepower[1])) && (!isArray(five_Miles_per_Gallon) || (+invert("y", five_y)[0] === +five_Miles_per_Gallon[0] && +invert("y", five_y)[1] === +five_Miles_per_Gallon[1])) ? five_scale_trigger : {}' + } + ] + } + ]) + ); + + const sixSg = interval.signals(model, selCmpts['six'], []); + expect(sixSg).toEqual( + expect.arrayContaining([ + { + name: 'six_x', + init: '[scale("x", datetime(2000, 9, 5, 0, 0, 0, 0)), scale("x", datetime(2001, 0, 13, 0, 0, 0, 0))]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[six_x[0], clamp(x(unit), 0, width)]' + }, + { + events: {signal: 'six_scale_trigger'}, + update: '[scale("x", six_Horsepower[0]), scale("x", six_Horsepower[1])]' + } + ] + }, + { + name: 'six_Horsepower', + init: '[datetime(2000, 9, 5, 0, 0, 0, 0), datetime(2001, 0, 13, 0, 0, 0, 0)]', + on: [ + { + events: {signal: 'six_x'}, + update: 'six_x[0] === six_x[1] ? null : invert("x", six_x)' + } + ] + }, + { + name: 'six_scale_trigger', + value: {}, + on: [ + { + events: [{scale: 'x'}], + update: + '(!isArray(six_Horsepower) || (+invert("x", six_x)[0] === +six_Horsepower[0] && +invert("x", six_x)[1] === +six_Horsepower[1])) ? six_scale_trigger : {}' + } + ] + } + ]) + ); }); - const threeSg = interval.signals(model, selCmpts['thr_ee'], []); - expect(threeSg).toEqual( - expect.arrayContaining([ - { - name: 'thr_ee_x', - value: [], - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[mousedown, mouseup] > mousemove', 'scope')[0], - update: '[thr_ee_x[0], clamp(x(unit), 0, width)]' - }, - { - events: parseSelector('keydown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[keydown, keyup] > keypress', 'scope')[0], - update: '[thr_ee_x[0], clamp(x(unit), 0, width)]' - }, - { - events: {signal: 'thr_ee_scale_trigger'}, - update: '[scale("x", thr_ee_Horsepower[0]), scale("x", thr_ee_Horsepower[1])]' - } - ] - }, - { - name: 'thr_ee_Horsepower', - on: [ - { - events: {signal: 'thr_ee_x'}, - update: 'thr_ee_x[0] === thr_ee_x[1] ? null : invert("x", thr_ee_x)' - } - ] - }, - { - name: 'thr_ee_y', - value: [], - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[y(unit), y(unit)]' - }, - { - events: parseSelector('[mousedown, mouseup] > mousemove', 'scope')[0], - update: '[thr_ee_y[0], clamp(y(unit), 0, height)]' - }, - { - events: parseSelector('keydown', 'scope')[0], - update: '[y(unit), y(unit)]' - }, - { - events: parseSelector('[keydown, keyup] > keypress', 'scope')[0], - update: '[thr_ee_y[0], clamp(y(unit), 0, height)]' - }, - { - events: {signal: 'thr_ee_scale_trigger'}, - update: '[scale("y", thr_ee_Miles_per_Gallon[0]), scale("y", thr_ee_Miles_per_Gallon[1])]' - } - ] - }, - { - name: 'thr_ee_Miles_per_Gallon', - on: [ - { - events: {signal: 'thr_ee_y'}, - update: 'thr_ee_y[0] === thr_ee_y[1] ? null : invert("y", thr_ee_y)' - } - ] - }, - { - name: 'thr_ee_scale_trigger', - value: {}, - on: [ - { - events: [{scale: 'x'}, {scale: 'y'}], - update: - '(!isArray(thr_ee_Horsepower) || (+invert("x", thr_ee_x)[0] === +thr_ee_Horsepower[0] && +invert("x", thr_ee_x)[1] === +thr_ee_Horsepower[1])) && (!isArray(thr_ee_Miles_per_Gallon) || (+invert("y", thr_ee_y)[0] === +thr_ee_Miles_per_Gallon[0] && +invert("y", thr_ee_y)[1] === +thr_ee_Miles_per_Gallon[1])) ? thr_ee_scale_trigger : {}' - } - ] - } - ]) - ); + it('builds trigger signals', () => { + const oneSg = interval.signals(model, selCmpts['one'], []); + expect(oneSg).toContainEqual({ + name: 'one_tuple', + on: [ + { + events: [{signal: 'one_Horsepower'}], + update: 'one_Horsepower ? {unit: "", fields: one_tuple_fields, values: [one_Horsepower]} : null' + } + ] + }); - const fourSg = interval.signals(model, selCmpts['four'], []); - expect(fourSg).toEqual( - expect.arrayContaining([ - { - name: 'four_x', - init: '[scale("x", 50), scale("x", 70)]', - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], - update: '[four_x[0], clamp(x(unit), 0, width)]' - }, - { - events: {signal: 'four_scale_trigger'}, - update: '[scale("x", four_Horsepower[0]), scale("x", four_Horsepower[1])]' - } - ] - }, - { - name: 'four_Horsepower', - init: '[50, 70]', - on: [ - { - events: {signal: 'four_x'}, - update: 'four_x[0] === four_x[1] ? null : invert("x", four_x)' - } - ] - }, - { - name: 'four_scale_trigger', - value: {}, - on: [ - { - events: [{scale: 'x'}], - update: - '(!isArray(four_Horsepower) || (+invert("x", four_x)[0] === +four_Horsepower[0] && +invert("x", four_x)[1] === +four_Horsepower[1])) ? four_scale_trigger : {}' - } - ] + const twoSg = interval.signals(model, selCmpts['two'], []); + expect(twoSg).toContainEqual({ + name: 'two_tuple', + on: [ + { + events: [{signal: 'two_Miles_per_Gallon'}], + update: + 'two_Miles_per_Gallon ? {unit: "", fields: two_tuple_fields, values: [two_Miles_per_Gallon]} : null' + } + ] + }); + + const threeSg = interval.signals(model, selCmpts['thr_ee'], []); + expect(threeSg).toContainEqual({ + name: 'thr_ee_tuple', + on: [ + { + events: [{signal: 'thr_ee_Horsepower || thr_ee_Miles_per_Gallon'}], + update: + 'thr_ee_Horsepower && thr_ee_Miles_per_Gallon ? {unit: "", fields: thr_ee_tuple_fields, values: [thr_ee_Horsepower,thr_ee_Miles_per_Gallon]} : null' + } + ] + }); + + const fourSg = interval.signals(model, selCmpts['four'], []); + expect(fourSg).toContainEqual({ + name: 'four_tuple', + init: '{unit: "", fields: four_tuple_fields, values: [[50, 70]]}', + on: [ + { + events: [{signal: 'four_Horsepower'}], + update: 'four_Horsepower ? {unit: "", fields: four_tuple_fields, values: [four_Horsepower]} : null' + } + ] + }); + + const fiveSg = interval.signals(model, selCmpts['five'], []); + expect(fiveSg).toContainEqual({ + name: 'five_tuple', + init: '{unit: "", fields: five_tuple_fields, values: [[50, 60], [23, 54]]}', + on: [ + { + events: [{signal: 'five_Horsepower || five_Miles_per_Gallon'}], + update: + 'five_Horsepower && five_Miles_per_Gallon ? {unit: "", fields: five_tuple_fields, values: [five_Horsepower,five_Miles_per_Gallon]} : null' + } + ] + }); + }); + + it('namespaces signals when encoding/fields collide', () => { + const model2 = parseUnitModel({ + mark: 'circle', + encoding: { + x: {field: 'x', type: 'quantitative'}, + y: {field: 'y', type: 'quantitative'} } - ]) - ); + }); - const fiveSg = interval.signals(model, selCmpts['five'], []); - expect(fiveSg).toEqual( - expect.arrayContaining([ - { - name: 'five_x', - init: '[scale("x", 50), scale("x", 60)]', - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], - update: '[five_x[0], clamp(x(unit), 0, width)]' - }, - { - events: {signal: 'five_scale_trigger'}, - update: '[scale("x", five_Horsepower[0]), scale("x", five_Horsepower[1])]' - } - ] - }, - { - name: 'five_Horsepower', - init: '[50, 60]', - on: [ - { - events: {signal: 'five_x'}, - update: 'five_x[0] === five_x[1] ? null : invert("x", five_x)' - } - ] - }, - { - name: 'five_y', - init: '[scale("y", 23), scale("y", 54)]', - on: [ - { - events: parseSelector('mousedown', 'scope')[0], - update: '[y(unit), y(unit)]' - }, - { - events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], - update: '[five_y[0], clamp(y(unit), 0, height)]' - }, - { - events: {signal: 'five_scale_trigger'}, - update: '[scale("y", five_Miles_per_Gallon[0]), scale("y", five_Miles_per_Gallon[1])]' - } - ] - }, - { - name: 'five_Miles_per_Gallon', - init: '[23, 54]', - on: [ - { - events: {signal: 'five_y'}, - update: 'five_y[0] === five_y[1] ? null : invert("y", five_y)' - } - ] - }, + model2.parseScale(); + + const selCmpts2 = (model2.component.selection = parseUnitSelection(model2, [ { - name: 'five_scale_trigger', - value: {}, - on: [ - { - events: [{scale: 'x'}, {scale: 'y'}], - update: - '(!isArray(five_Horsepower) || (+invert("x", five_x)[0] === +five_Horsepower[0] && +invert("x", five_x)[1] === +five_Horsepower[1])) && (!isArray(five_Miles_per_Gallon) || (+invert("y", five_y)[0] === +five_Miles_per_Gallon[0] && +invert("y", five_y)[1] === +five_Miles_per_Gallon[1])) ? five_scale_trigger : {}' - } - ] + name: 'one', + select: { + type: 'interval', + encodings: ['x'], + translate: false, + zoom: false + } } - ]) - ); + ])); - const sixSg = interval.signals(model, selCmpts['six'], []); - expect(sixSg).toEqual( + const sg = interval.signals(model, selCmpts2['one'], []); + expect(sg[0].name).toBe('one_x_1'); + expect(sg[1].name).toBe('one_x'); + }); + }); + + it('builds modify signals', () => { + const signals = assembleUnitSelectionSignals(model, []); + expect(signals).toEqual( expect.arrayContaining([ { - name: 'six_x', - init: '[scale("x", datetime(2000, 9, 5, 0, 0, 0, 0)), scale("x", datetime(2001, 0, 13, 0, 0, 0, 0))]', + name: 'one_modify', on: [ { - events: parseSelector('mousedown', 'scope')[0], - update: '[x(unit), x(unit)]' - }, - { - events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], - update: '[six_x[0], clamp(x(unit), 0, width)]' - }, - { - events: {signal: 'six_scale_trigger'}, - update: '[scale("x", six_Horsepower[0]), scale("x", six_Horsepower[1])]' + events: {signal: 'one_tuple'}, + update: `modify("one_store", one_tuple, true)` } ] }, { - name: 'six_Horsepower', - init: '[datetime(2000, 9, 5, 0, 0, 0, 0), datetime(2001, 0, 13, 0, 0, 0, 0)]', + name: 'two_modify', on: [ { - events: {signal: 'six_x'}, - update: 'six_x[0] === six_x[1] ? null : invert("x", six_x)' + events: {signal: 'two_tuple'}, + update: `modify("two_store", two_tuple, true)` } ] }, { - name: 'six_scale_trigger', - value: {}, + name: 'thr_ee_modify', on: [ { - events: [{scale: 'x'}], - update: - '(!isArray(six_Horsepower) || (+invert("x", six_x)[0] === +six_Horsepower[0] && +invert("x", six_x)[1] === +six_Horsepower[1])) ? six_scale_trigger : {}' + events: {signal: 'thr_ee_tuple'}, + update: `modify("thr_ee_store", thr_ee_tuple, {unit: ""})` } ] } @@ -393,446 +519,538 @@ describe('Interval Selections', () => { ); }); - it('builds trigger signals', () => { - const oneSg = interval.signals(model, selCmpts['one'], []); - expect(oneSg).toContainEqual({ - name: 'one_tuple', - on: [ - { - events: [{signal: 'one_Horsepower'}], - update: 'one_Horsepower ? {unit: "", fields: one_tuple_fields, values: [one_Horsepower]} : null' + it('builds brush mark', () => { + const marks: any[] = [{hello: 'world'}]; + expect(interval.marks(model, selCmpts['one'], marks)).toEqual([ + { + name: 'one_brush_bg', + type: 'rect', + clip: true, + encode: { + enter: { + fill: {value: '#333'}, + fillOpacity: {value: 0.125} + }, + update: { + x: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + signal: 'one_x[0]' + }, + { + value: 0 + } + ], + y: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + value: 0 + }, + { + value: 0 + } + ], + x2: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + signal: 'one_x[1]' + }, + { + value: 0 + } + ], + y2: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + field: { + group: 'height' + } + }, + { + value: 0 + } + ] + } } - ] - }); - - const twoSg = interval.signals(model, selCmpts['two'], []); - expect(twoSg).toContainEqual({ - name: 'two_tuple', - on: [ - { - events: [{signal: 'two_Miles_per_Gallon'}], - update: 'two_Miles_per_Gallon ? {unit: "", fields: two_tuple_fields, values: [two_Miles_per_Gallon]} : null' + }, + {hello: 'world'}, + { + name: 'one_brush', + type: 'rect', + clip: true, + encode: { + enter: { + fill: {value: 'transparent'} + }, + update: { + stroke: [ + { + test: 'one_x[0] !== one_x[1]', + value: 'white' + }, + { + value: null + } + ], + x: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + signal: 'one_x[0]' + }, + { + value: 0 + } + ], + y: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + value: 0 + }, + { + value: 0 + } + ], + x2: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + signal: 'one_x[1]' + }, + { + value: 0 + } + ], + y2: [ + { + test: 'data("one_store").length && data("one_store")[0].unit === ""', + field: { + group: 'height' + } + }, + { + value: 0 + } + ] + } } - ] - }); + } + ]); - const threeSg = interval.signals(model, selCmpts['thr_ee'], []); - expect(threeSg).toContainEqual({ - name: 'thr_ee_tuple', - on: [ - { - events: [{signal: 'thr_ee_Horsepower || thr_ee_Miles_per_Gallon'}], - update: - 'thr_ee_Horsepower && thr_ee_Miles_per_Gallon ? {unit: "", fields: thr_ee_tuple_fields, values: [thr_ee_Horsepower,thr_ee_Miles_per_Gallon]} : null' - } - ] - }); + // Scale-bound interval selections should not add a brush mark. + expect(interval.marks(model, selCmpts['two'], marks)).toEqual(marks); - const fourSg = interval.signals(model, selCmpts['four'], []); - expect(fourSg).toContainEqual({ - name: 'four_tuple', - init: '{unit: "", fields: four_tuple_fields, values: [[50, 70]]}', - on: [ - { - events: [{signal: 'four_Horsepower'}], - update: 'four_Horsepower ? {unit: "", fields: four_tuple_fields, values: [four_Horsepower]} : null' + expect(interval.marks(model, selCmpts['thr_ee'], marks)).toEqual([ + { + name: 'thr_ee_brush_bg', + type: 'rect', + clip: true, + encode: { + enter: { + fill: {value: 'red'}, + fillOpacity: {value: 0.75} + }, + update: { + x: { + signal: 'thr_ee_x[0]' + }, + y: { + signal: 'thr_ee_y[0]' + }, + x2: { + signal: 'thr_ee_x[1]' + }, + y2: { + signal: 'thr_ee_y[1]' + } + } } - ] - }); - - const fiveSg = interval.signals(model, selCmpts['five'], []); - expect(fiveSg).toContainEqual({ - name: 'five_tuple', - init: '{unit: "", fields: five_tuple_fields, values: [[50, 60], [23, 54]]}', - on: [ - { - events: [{signal: 'five_Horsepower || five_Miles_per_Gallon'}], - update: - 'five_Horsepower && five_Miles_per_Gallon ? {unit: "", fields: five_tuple_fields, values: [five_Horsepower,five_Miles_per_Gallon]} : null' + }, + {hello: 'world'}, + { + name: 'thr_ee_brush', + type: 'rect', + clip: true, + encode: { + enter: { + fill: {value: 'transparent'} + }, + update: { + stroke: [ + { + test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', + value: 'black' + }, + {value: null} + ], + strokeWidth: [ + { + test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', + value: 4 + }, + {value: null} + ], + strokeDash: [ + { + test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', + value: [10, 5] + }, + {value: null} + ], + strokeDashOffset: [ + { + test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', + value: 3 + }, + {value: null} + ], + strokeOpacity: [ + { + test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', + value: 0.25 + }, + {value: null} + ], + x: { + signal: 'thr_ee_x[0]' + }, + y: { + signal: 'thr_ee_y[0]' + }, + x2: { + signal: 'thr_ee_x[1]' + }, + y2: { + signal: 'thr_ee_y[1]' + } + } } - ] - }); + } + ]); }); - it('namespaces signals when encoding/fields collide', () => { - const model2 = parseUnitModel({ + it('should be robust to same channel/field names', () => { + const nameModel = parseUnitModel({ mark: 'circle', encoding: { x: {field: 'x', type: 'quantitative'}, y: {field: 'y', type: 'quantitative'} } }); + nameModel.parseScale(); - model2.parseScale(); - - const selCmpts2 = (model2.component.selection = parseUnitSelection(model2, [ + const nameSelCmpts = (nameModel.component.selection = parseUnitSelection(nameModel, [ { - name: 'one', - select: { - type: 'interval', - encodings: ['x'], - translate: false, - zoom: false - } + name: 'brush', + select: 'interval' } ])); - const sg = interval.signals(model, selCmpts2['one'], []); - expect(sg[0].name).toBe('one_x_1'); - expect(sg[1].name).toBe('one_x'); - }); - }); + const signals = interval.signals(nameModel, nameSelCmpts['brush'], []); + const names = signals.map(s => s.name); + expect(names).toEqual(expect.arrayContaining(['brush_x_1', 'brush_x', 'brush_y_1', 'brush_y'])); - 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)` - } - ] - }, + const marks: any[] = [{hello: 'world'}]; + expect(interval.marks(nameModel, nameSelCmpts['brush'], marks)).toEqual([ { - name: 'two_modify', - on: [ - { - events: {signal: 'two_tuple'}, - update: `modify("two_store", two_tuple, true)` + name: 'brush_brush_bg', + type: 'rect', + clip: true, + encode: { + enter: {fill: {value: '#333'}, fillOpacity: {value: 0.125}}, + update: { + x: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_x_1[0]' + }, + {value: 0} + ], + y: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_y_1[0]' + }, + {value: 0} + ], + x2: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_x_1[1]' + }, + {value: 0} + ], + y2: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_y_1[1]' + }, + {value: 0} + ] } - ] + } }, + {hello: 'world'}, { - name: 'thr_ee_modify', - on: [ - { - events: {signal: 'thr_ee_tuple'}, - update: `modify("thr_ee_store", thr_ee_tuple, {unit: ""})` + name: 'brush_brush', + type: 'rect', + clip: true, + encode: { + enter: {fill: {value: 'transparent'}}, + update: { + x: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_x_1[0]' + }, + {value: 0} + ], + y: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_y_1[0]' + }, + {value: 0} + ], + x2: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_x_1[1]' + }, + {value: 0} + ], + y2: [ + { + test: 'data("brush_store").length && data("brush_store")[0].unit === ""', + signal: 'brush_y_1[1]' + }, + {value: 0} + ], + stroke: [ + { + test: 'brush_x_1[0] !== brush_x_1[1] && brush_y_1[0] !== brush_y_1[1]', + value: 'white' + }, + {value: null} + ] } - ] + } } - ]) - ); + ]); + }); }); - it('builds brush mark', () => { - const marks: any[] = [{hello: 'world'}]; - expect(interval.marks(model, selCmpts['one'], marks)).toEqual([ - { - name: 'one_brush_bg', - type: 'rect', - clip: true, - encode: { - enter: { - fill: {value: '#333'}, - fillOpacity: {value: 0.125} - }, - update: { - x: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - signal: 'one_x[0]' - }, - { - value: 0 - } - ], - y: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - value: 0 - }, - { - value: 0 - } - ], - x2: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - signal: 'one_x[1]' - }, - { - value: 0 - } - ], - y2: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - field: { - group: 'height' - } - }, - { - value: 0 - } - ] - } + describe('Geo intervals', () => { + const model = parseUnitModelWithScaleAndLayoutSize({ + data: { + url: 'data/airports.csv', + format: { + type: 'csv' } }, - {hello: 'world'}, - { - name: 'one_brush', - type: 'rect', - clip: true, - encode: { - enter: { - fill: {value: 'transparent'} - }, - update: { - stroke: [ - { - test: 'one_x[0] !== one_x[1]', - value: 'white' - }, - { - value: null - } - ], - x: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - signal: 'one_x[0]' - }, - { - value: 0 - } - ], - y: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - value: 0 - }, - { - value: 0 - } - ], - x2: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - signal: 'one_x[1]' - }, - { - value: 0 - } - ], - y2: [ - { - test: 'data("one_store").length && data("one_store")[0].unit === ""', - field: { - group: 'height' - } - }, - { - value: 0 - } - ] - } + mark: 'circle', + projection: { + type: 'albersUsa' + }, + encoding: { + longitude: { + field: 'longitude', + type: 'quantitative' + }, + latitude: { + field: 'latitude', + type: 'quantitative' } } - ]); - - // Scale-bound interval selections should not add a brush mark. - expect(interval.marks(model, selCmpts['two'], marks)).toEqual(marks); + }); + model.parseProjection(); - expect(interval.marks(model, selCmpts['thr_ee'], marks)).toEqual([ + const selCmpts = (model.component.selection = parseUnitSelection(model, [ { - name: 'thr_ee_brush_bg', - type: 'rect', - clip: true, - encode: { - enter: { - fill: {value: 'red'}, - fillOpacity: {value: 0.75} - }, - update: { - x: { - signal: 'thr_ee_x[0]' - }, - y: { - signal: 'thr_ee_y[0]' - }, - x2: { - signal: 'thr_ee_x[1]' - }, - y2: { - signal: 'thr_ee_y[1]' - } - } + name: 'one', + select: {type: 'interval', clear: false, translate: false, zoom: false} + }, + { + name: 'two', + select: {type: 'interval', encodings: ['longitude'], clear: false, translate: false, zoom: false} + }, + { + name: 'three', + select: {type: 'interval', clear: false, translate: false, zoom: false}, + value: { + latitude: [30, 40], + longitude: [-86, -118] } }, - {hello: 'world'}, { - name: 'thr_ee_brush', - type: 'rect', - clip: true, - encode: { - enter: { - fill: {value: 'transparent'} - }, - update: { - stroke: [ - { - test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', - value: 'black' - }, - {value: null} - ], - strokeWidth: [ - { - test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', - value: 4 - }, - {value: null} - ], - strokeDash: [ - { - test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', - value: [10, 5] - }, - {value: null} - ], - strokeDashOffset: [ - { - test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', - value: 3 - }, - {value: null} - ], - strokeOpacity: [ - { - test: 'thr_ee_x[0] !== thr_ee_x[1] && thr_ee_y[0] !== thr_ee_y[1]', - value: 0.25 - }, - {value: null} - ], - x: { - signal: 'thr_ee_x[0]' + name: 'four', + select: {type: 'interval', clear: false, translate: false, zoom: false}, + value: {latitude: [30, 40]} + } + ])); + + describe('Tuple Signals', () => { + it('builds projection signals', () => { + const oneSg = interval.signals(model, selCmpts['one'], []); + expect(oneSg).toEqual( + expect.arrayContaining([ + { + name: 'one_latitude_1', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[one_latitude_1[0], clamp(y(unit), 0, height)]' + } + ] + }, + { + name: 'one_longitude_1', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[one_longitude_1[0], clamp(x(unit), 0, width)]' + } + ] + } + ]) + ); + + const twoSg = interval.signals(model, selCmpts['two'], []); + expect(twoSg).toEqual( + expect.arrayContaining([ + { + name: 'two_longitude_1', + value: [], + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[two_longitude_1[0], clamp(x(unit), 0, width)]' + } + ] + } + ]) + ); + + const threeSg = interval.signals(model, selCmpts['three'], []); + expect(threeSg).toEqual( + expect.arrayContaining([ + { + name: 'three_init', + init: '[scale("projection", [-86, 30]), scale("projection", [-118, 40])]' + }, + { + name: 'three_latitude_1', + init: '[three_init[0][1], three_init[1][1]]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[three_latitude_1[0], clamp(y(unit), 0, height)]' + } + ] }, - y: { - signal: 'thr_ee_y[0]' + { + name: 'three_longitude_1', + init: '[three_init[0][0], three_init[1][0]]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[x(unit), x(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[three_longitude_1[0], clamp(x(unit), 0, width)]' + } + ] + } + ]) + ); + + const fourSg = interval.signals(model, selCmpts['four'], []); + expect(fourSg).toEqual( + expect.arrayContaining([ + { + name: 'projection_center', + update: 'invert("projection", [width/2, height/2])' }, - x2: { - signal: 'thr_ee_x[1]' + { + name: 'four_init', + init: '[scale("projection", [projection_center[0], 30]), scale("projection", [projection_center[0], 40])]' }, - y2: { - signal: 'thr_ee_y[1]' + { + name: 'four_latitude_1', + init: '[four_init[0][1], four_init[1][1]]', + on: [ + { + events: parseSelector('mousedown', 'scope')[0], + update: '[y(unit), y(unit)]' + }, + { + events: parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')[0], + update: '[four_latitude_1[0], clamp(y(unit), 0, height)]' + } + ] } - } - } - } - ]); - }); + ]) + ); + }); - it('should be robust to same channel/field names', () => { - const nameModel = parseUnitModel({ - mark: 'circle', - encoding: { - x: {field: 'x', type: 'quantitative'}, - y: {field: 'y', type: 'quantitative'} - } - }); - nameModel.parseScale(); + it('builds trigger signals', () => { + const oneSg = interval.signals(model, selCmpts['one'], []); + expect(oneSg).toContainEqual({ + name: 'one_tuple', + on: [ + { + events: [{signal: 'one_longitude_1 || one_latitude_1'}], + update: + 'vlSelectionTuples(intersect([[one_longitude_1[0], one_latitude_1[0]],[one_longitude_1[1], one_latitude_1[1]]], {markname: "marks"}, unit.mark), {unit: ""})' + } + ] + }); - const nameSelCmpts = (nameModel.component.selection = parseUnitSelection(nameModel, [ - { - name: 'brush', - select: 'interval' - } - ])); + const twoSg = interval.signals(model, selCmpts['two'], []); + expect(twoSg).toContainEqual({ + name: 'two_tuple', + on: [ + { + events: [{signal: 'two_longitude_1'}], + update: + 'vlSelectionTuples(intersect([[two_longitude_1[0], 0],[two_longitude_1[1], height]], {markname: "marks"}, unit.mark), {unit: ""})' + } + ] + }); - const signals = interval.signals(nameModel, nameSelCmpts['brush'], []); - const names = signals.map(s => s.name); - expect(names).toEqual(expect.arrayContaining(['brush_x_1', 'brush_x', 'brush_y_1', 'brush_y'])); + const threeSg = interval.signals(model, selCmpts['three'], []); + let update = + 'vlSelectionTuples(intersect([[three_longitude_1[0], three_latitude_1[0]],[three_longitude_1[1], three_latitude_1[1]]], {markname: "marks"}, unit.mark), {unit: ""})'; + expect(threeSg).toContainEqual({ + name: 'three_tuple', + on: [{events: [{signal: 'three_latitude_1 || three_longitude_1'}, {signal: GEO_INIT_TICK}], update}] + }); - const marks: any[] = [{hello: 'world'}]; - expect(interval.marks(nameModel, nameSelCmpts['brush'], marks)).toEqual([ - { - name: 'brush_brush_bg', - type: 'rect', - clip: true, - encode: { - enter: {fill: {value: '#333'}, fillOpacity: {value: 0.125}}, - update: { - x: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_x_1[0]' - }, - {value: 0} - ], - y: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_y_1[0]' - }, - {value: 0} - ], - x2: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_x_1[1]' - }, - {value: 0} - ], - y2: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_y_1[1]' - }, - {value: 0} - ] - } - } - }, - {hello: 'world'}, - { - name: 'brush_brush', - type: 'rect', - clip: true, - encode: { - enter: {fill: {value: 'transparent'}}, - update: { - x: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_x_1[0]' - }, - {value: 0} - ], - y: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_y_1[0]' - }, - {value: 0} - ], - x2: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_x_1[1]' - }, - {value: 0} - ], - y2: [ - { - test: 'data("brush_store").length && data("brush_store")[0].unit === ""', - signal: 'brush_y_1[1]' - }, - {value: 0} - ], - stroke: [ - { - test: 'brush_x_1[0] !== brush_x_1[1] && brush_y_1[0] !== brush_y_1[1]', - value: 'white' - }, - {value: null} - ] - } - } - } - ]); + const fourSg = interval.signals(model, selCmpts['four'], []); + update = + 'vlSelectionTuples(intersect([[0, four_latitude_1[0]],[width, four_latitude_1[1]]], {markname: "marks"}, unit.mark), {unit: ""})'; + expect(fourSg).toContainEqual({ + name: 'four_tuple', + on: [{events: [{signal: 'four_latitude_1'}, {signal: GEO_INIT_TICK}], update}] + }); + }); + }); }); }); diff --git a/test/compile/selection/parse.test.ts b/test/compile/selection/parse.test.ts index 0e04759ad8..d5b8233752 100644 --- a/test/compile/selection/parse.test.ts +++ b/test/compile/selection/parse.test.ts @@ -29,7 +29,7 @@ describe('Selection', () => { expect(component.one.name).toBe('one'); expect(component.one.type).toBe('point'); expect(component['one'].project.items).toEqual( - expect.arrayContaining([{field: '_vgsid_', type: 'E', signals: {data: 'one__vgsid_'}}]) + expect.arrayContaining([{field: '_vgsid_', index: 0, type: 'E', signals: {data: 'one__vgsid_'}}]) ); expect(component['one'].events).toEqual(parseSelector('click', 'scope')); @@ -39,16 +39,28 @@ describe('Selection', () => { expect(component.two.zoom).toBe('wheel!'); expect(component['two'].project.items).toEqual( expect.arrayContaining([ - {field: 'Horsepower', channel: 'x', type: 'R', signals: {data: 'two_Horsepower', visual: 'two_x'}}, + { + field: 'Horsepower', + channel: 'x', + index: 0, + type: 'R', + signals: {data: 'two_Horsepower', visual: 'two_x'} + }, { field: 'Miles_per_Gallon', channel: 'y', + index: 1, type: 'R', signals: {data: 'two_Miles_per_Gallon', visual: 'two_y'} } ]) ); - expect(component['two'].events).toEqual(parseSelector('[mousedown, window:mouseup] > window:mousemove!', 'scope')); + expect(component['two'].events).toEqual( + parseSelector( + '[mousedown[!event.item || event.item.mark.name !== "two_brush"], window:mouseup] > window:mousemove!', + 'scope' + ) + ); }); it('supports inline default overrides', () => { @@ -87,7 +99,7 @@ describe('Selection', () => { expect(component.one.name).toBe('one'); expect(component.one.type).toBe('point'); expect(component['one'].project.items).toEqual( - expect.arrayContaining([{field: 'Cylinders', type: 'E', signals: {data: 'one_Cylinders'}}]) + expect.arrayContaining([{field: 'Cylinders', index: 0, type: 'E', signals: {data: 'one_Cylinders'}}]) ); expect(component['one'].events).toEqual(parseSelector('dblclick', 'scope')); @@ -96,7 +108,7 @@ describe('Selection', () => { expect(component.two.toggle).toBe('event.ctrlKey'); expect(component['two'].project.items).toEqual( expect.arrayContaining([ - {field: 'Origin', channel: 'color', type: 'E', signals: {data: 'two_Origin', visual: 'two_color'}} + {field: 'Origin', channel: 'color', index: 0, type: 'E', signals: {data: 'two_Origin', visual: 'two_color'}} ]) ); expect(component['two'].events).toEqual(parseSelector('mouseover', 'scope')); @@ -110,6 +122,7 @@ describe('Selection', () => { { field: 'Miles_per_Gallon', channel: 'y', + index: 0, type: 'R', signals: {data: 'three_Miles_per_Gallon', visual: 'three_y'} } @@ -142,7 +155,7 @@ describe('Selection', () => { expect(component.one.toggle).toBe('event.ctrlKey'); expect(component['one'].project.items).toEqual( expect.arrayContaining([ - {field: 'Origin', channel: 'color', type: 'E', signals: {data: 'one_Origin', visual: 'one_color'}} + {field: 'Origin', channel: 'color', index: 0, type: 'E', signals: {data: 'one_Origin', visual: 'one_color'}} ]) ); expect(component['one'].events).toEqual(parseSelector('mouseover', 'scope')); @@ -156,6 +169,7 @@ describe('Selection', () => { { field: 'Miles_per_Gallon', channel: 'y', + index: 0, type: 'R', signals: {data: 'two_Miles_per_Gallon', visual: 'two_y'} } @@ -182,7 +196,7 @@ describe('Selection', () => { expect(c['one'].project.items).toEqual( expect.arrayContaining([ - {field: 'Origin', channel: 'x', type: 'E', signals: {data: 'one_Origin', visual: 'one_x'}} + {field: 'Origin', channel: 'x', index: 0, type: 'E', signals: {data: 'one_Origin', visual: 'one_x'}} ]) ); @@ -200,7 +214,7 @@ describe('Selection', () => { expect(c['one'].project.items).toEqual( expect.arrayContaining([ - {field: 'Origin', channel: 'x', type: 'E', signals: {data: 'one_Origin', visual: 'one_x'}} + {field: 'Origin', channel: 'x', index: 0, type: 'E', signals: {data: 'one_Origin', visual: 'one_x'}} ]) ); }); @@ -218,7 +232,13 @@ describe('Selection', () => { expect(c['one'].project.items).toEqual( expect.arrayContaining([ - {field: 'Acceleration', channel: 'x', type: 'R-RE', signals: {data: 'one_Acceleration', visual: 'one_x'}} + { + field: 'Acceleration', + channel: 'x', + index: 0, + type: 'R-RE', + signals: {data: 'one_Acceleration', visual: 'one_x'} + } ]) ); }); @@ -243,18 +263,24 @@ describe('Selection', () => { ]); expect(component['one'].project.items).toEqual( - expect.arrayContaining([{field: 'Origin', type: 'E', signals: {data: 'one_Origin'}}]) + expect.arrayContaining([{field: 'Origin', index: 0, type: 'E', signals: {data: 'one_Origin'}}]) ); expect(component['two'].project.items).toEqual( expect.arrayContaining([ - {channel: 'color', field: 'Origin', type: 'E', signals: {data: 'two_Origin', visual: 'two_color'}} + {channel: 'color', field: 'Origin', index: 0, type: 'E', signals: {data: 'two_Origin', visual: 'two_color'}} ]) ); expect(component['three'].project.items).toEqual( expect.arrayContaining([ - {field: 'Horsepower', channel: 'x', type: 'R', signals: {data: 'three_Horsepower', visual: 'three_x'}} + { + field: 'Horsepower', + channel: 'x', + index: 0, + type: 'R', + signals: {data: 'three_Horsepower', visual: 'three_x'} + } ]) ); }); @@ -268,8 +294,8 @@ describe('Selection', () => { ]); expect(component['one'].project.items).toEqual([ - {field: 'nested.a', type: 'E', signals: {data: 'one_nested_a'}}, - {field: 'nested.b.aa', type: 'E', signals: {data: 'one_nested_b_aa'}} + {field: 'nested.a', index: 0, type: 'E', signals: {data: 'one_nested_a'}}, + {field: 'nested.b.aa', index: 1, type: 'E', signals: {data: 'one_nested_b_aa'}} ]); expect(project.signals(null, component['one'], [])).toEqual([ @@ -292,8 +318,14 @@ describe('Selection', () => { ]); expect(component['one'].project.items).toEqual([ - {field: 'Horsepower', channel: 'x', type: 'E', signals: {data: 'one_Horsepower', visual: 'one_x'}}, - {field: 'Miles_per_Gallon', channel: 'y', type: 'E', signals: {data: 'one_Miles_per_Gallon', visual: 'one_y'}} + {field: 'Horsepower', channel: 'x', index: 0, type: 'E', signals: {data: 'one_Horsepower', visual: 'one_x'}}, + { + field: 'Miles_per_Gallon', + channel: 'y', + index: 1, + type: 'E', + signals: {data: 'one_Miles_per_Gallon', visual: 'one_y'} + } ]); }); });