Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add expr functions for extensional selection predicates #3009

Merged
merged 1 commit into from Jan 21, 2021

Conversation

arvind
Copy link
Member

@arvind arvind commented Dec 15, 2020

This PR adds two new expression functions to support extensional predicates for Vega-Lite selections. This is (currently) most useful to support interval selections over cartographic projections (vega/vega-lite#6953).

An example output Vega spec for Vega-Lite geo-interval selections
{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "background": "white",
  "padding": 5,
  "width": 500,
  "height": 300,
  "style": "cell",
  "data": [
    {
      "name": "brush_store",
      "transform": [
        {"type": "formula", "expr": "datum.values[0]", "as": "_vgsid_"},
        {"type": "collect", "sort": {"field": "_vgsid_"}}
      ]
    },
    {
      "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", "delimiter": ","},
      "transform": [
        {"type": "identifier", "as": "_vgsid_"},
        {
          "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": "brush",
      "update": "vlSelectionResolve(\"brush_store\", \"union\")"
    },
    {
      "name": "brush_x",
      "value": [],
      "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_x[0], clamp(x(unit), 0, width)]"
        },
        {
          "events": [{"source": "scope", "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, width)"
        },
        {
          "events": {"signal": "brush_zoom_delta"},
          "update": "clampRange(zoomLinear(brush_x, brush_zoom_anchor.x, brush_zoom_delta), 0, width)"
        }
      ]
    },
    {
      "name": "brush_y",
      "value": [],
      "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_y[0], clamp(y(unit), 0, height)]"
        },
        {
          "events": [{"source": "scope", "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_y, brush_zoom_anchor.y, brush_zoom_delta), 0, height)"
        }
      ]
    },
    {
      "name": "brush_tuple",
      "update": "vlSelectionTuples(intersect([[brush_x[0], brush_y[0]],[brush_x[1], brush_y[1]]], {markname: \"layer_1_marks\"}, unit.mark), {unit: \"layer_1\", fields: [brush_tuple_fields[0]]})"
    },
    {
      "name": "brush_tuple_fields",
      "value": [
        {"type": "E", "field": "_vgsid_"},
        {"field": "longitude", "channel": "x", "type": "E"},
        {"field": "latitude", "channel": "y", "type": "E"}
      ]
    },
    {
      "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_x), extent_y: slice(brush_y)}"
        }
      ]
    },
    {
      "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\"",
              "signal": "brush_x[0]"
            },
            {"value": 0}
          ],
          "y": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_y[0]"
            },
            {"value": 0}
          ],
          "x2": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_x[1]"
            },
            {"value": 0}
          ],
          "y2": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_y[1]"
            },
            {"value": 0}
          ]
        }
      }
    },
    {
      "name": "layer_0_marks",
      "type": "shape",
      "style": ["geoshape"],
      "interactive": false,
      "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": "red"
            },
            {"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\"",
              "signal": "brush_x[0]"
            },
            {"value": 0}
          ],
          "y": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_y[0]"
            },
            {"value": 0}
          ],
          "x2": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_x[1]"
            },
            {"value": 0}
          ],
          "y2": [
            {
              "test": "data(\"brush_store\").length && data(\"brush_store\")[0].unit === \"layer_1\"",
              "signal": "brush_y[1]"
            },
            {"value": 0}
          ],
          "stroke": [
            {
              "test": "brush_x[0] !== brush_x[1] && brush_y[0] !== brush_y[1]",
              "value": "white"
            },
            {"value": null}
          ]
        }
      }
    }
  ]
}

For the spec above, I'll call out the specific places these new functions are being used:

  1. To map from an array of scenegraph elements (picked via the intersect function) to selection tuples:
    {
      "name": "brush_tuple",
      "update": "vlSelectionTuples(intersect([[brush_x[0], brush_y[0]],[brush_x[1], brush_y[1]]], {markname: \"layer_1_marks\"}, unit.mark), {unit: \"layer_1\", fields: [brush_tuple_fields[0]]})"
    }
  1. Selection tuples are then sorted by their IDs:
"data": [
    {
      "name": "brush_store",
      "transform": [
        {"type": "formula", "expr": "datum.values[0]", "as": "_vgsid_"},
        {"type": "collect", "sort": {"field": "_vgsid_"}}
      ]
  1. And finally, we use vlSelectionIdTest to do a binary search over this dataset:
{
      "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": "red"
            },
            {"value": "steelblue"}
          ]

@arvind arvind requested a review from jheer December 15, 2020 16:01
@jheer jheer merged commit 5350881 into master Jan 21, 2021
@jheer jheer deleted the as/extensionalSelections branch January 21, 2021 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants