Repository: altair-viz/altair
Pull request #1707 (2019-09-26 14:11:12)

BUG: composing multiple selections
This PR has still some issues, but it adds some documentation + inline example to compose multiple selections as discussed in https://github.com/altair-viz/altair/issues/1704.

I tried to emulate the Vega-Lite example from [here](https://vega.github.io/vega-lite/docs/selection.html#compose), but preferred to hold the ctrl-key or shift-key to separately initiate the selections.

I don't know how to make the following Vega-Lite example with bitwise operators work in Altair:
```
"selection": {"not": {"and": ["alex", "morgan"]}}
```
I tried some (eg. `~alex & ~morgan` and `~[alex & morgan]`), but got none working.


Patch:
diff --git a/altair/vegalite/v3/api.py b/altair/vegalite/v3/api.py
index faf2b73c..1899a593 100644
--- a/altair/vegalite/v3/api.py
+++ b/altair/vegalite/v3/api.py
@@ -177,17 +177,17 @@ class Selection(object):
         return {'selection': self.name}
 
     def __invert__(self):
-        return core.SelectionNot(**{'not': self.name})
+        return Selection(core.SelectionNot(**{'not': self.name}), self.selection)
 
     def __and__(self, other):
         if isinstance(other, Selection):
             other = other.name
-        return core.SelectionAnd(**{'and': [self.name, other]})
+        return Selection(core.SelectionAnd(**{'and': [self.name, other]}), self.selection)      
 
     def __or__(self, other):
         if isinstance(other, Selection):
             other = other.name
-        return core.SelectionOr(**{'or': [self.name, other]})
+        return Selection(core.SelectionOr(**{'or': [self.name, other]}), self.selection)      
    
     def __getattr__(self, field_name):
         return expr.core.GetAttrExpression(self.name, field_name)
diff --git a/doc/user_guide/interactions.rst b/doc/user_guide/interactions.rst
index 293ded86..a364e194 100644
--- a/doc/user_guide/interactions.rst
+++ b/doc/user_guide/interactions.rst
@@ -251,6 +251,54 @@ over them with your mouse:
     multi_mouseover = alt.selection_multi(on='mouseover', toggle=False, empty='none')
     make_example(multi_mouseover)
 
+Composing Multiple Selections
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Altair also supports combining multiple selections using the ``&``, ``|``
+and ``~`` for respectively ``AND``, ``OR`` and ``NOT`` logical composition
+operands.
+
+In the following example there are two people who can make an interval
+selection in the chart. The person Alex makes a selection box when the
+alt-key (macOS: option-key) is selected and Morgan can make a selection
+box when the shift-key is selected.
+We use the alt.Brushconfig() to give the selection box of Morgan a different
+style.
+Now, we color the rectangles when they fall within Alex's or Morgan's
+selection.
+
+.. altair-plot::
+
+    alex = alt.selection_interval(
+        on="[mousedown[event.altKey], mouseup] > mousemove",
+        name='alex'
+    )
+    morgan = alt.selection_interval(
+        on="[mousedown[event.shiftKey], mouseup] > mousemove",
+        mark=alt.BrushConfig(fill="#fdbb84", fillOpacity=0.5, stroke="#e34a33"),
+        name='morgan'
+    )
+
+    alt.Chart(cars).mark_rect().encode(
+        x='Cylinders:O',
+        y='Origin:O',
+        color=alt.condition(alex | morgan, 'count()', alt.ColorValue("grey"))
+    ).add_selection(
+        alex, morgan
+    ).properties(
+        width=300,
+        height=180
+    )
+
+With these operators, selections can be combined in arbitrary ways:
+
+- ``~(alex & morgan)``: to select the rectangles that fall outside
+  Alex's and Morgan's selections.
+
+- ``alex | ~morgan``: to select the rectangles that fall within Alex's
+  selection or outside the selection of Morgan
+
+
 Selection Targets: Fields and Encodings
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 For any but the simplest selections, the user needs to think about exactly


Test patch:
diff --git a/altair/vegalite/v3/tests/test_api.py b/altair/vegalite/v3/tests/test_api.py
index c8d7995c..caaa9fc8 100644
--- a/altair/vegalite/v3/tests/test_api.py
+++ b/altair/vegalite/v3/tests/test_api.py
@@ -359,9 +359,12 @@ def test_selection():
     assert set(chart.selection.keys()) == {'selec_1', 'selec_2', 'selec_3'}
 
     # test logical operations
-    assert isinstance(single & multi, alt.SelectionAnd)
-    assert isinstance(single | multi, alt.SelectionOr)
-    assert isinstance(~single, alt.SelectionNot)
+    assert isinstance(single & multi, alt.Selection)
+    assert isinstance(single | multi, alt.Selection)
+    assert isinstance(~single, alt.Selection)
+    assert isinstance((single & multi)[0].group, alt.SelectionAnd)
+    assert isinstance((single | multi)[0].group, alt.SelectionOr)
+    assert isinstance((~single)[0].group, alt.SelectionNot)
 
     # test that default names increment (regression for #1454)
     sel1 = alt.selection_single()
@@ -475,6 +478,15 @@ def test_filter_transform_selection_predicates():
     chart = base.transform_filter(selector1 | selector2)
     assert chart.to_dict()['transform'] == [{'filter': {'selection': {'or': ['s1', 's2']}}}]
 
+    chart = base.transform_filter(selector1 | ~selector2)
+    assert chart.to_dict()['transform'] == [{'filter': {'selection': {'or': ['s1', {'not': 's2'}]}}}]
+
+    chart = base.transform_filter(~selector1 | ~selector2)
+    assert chart.to_dict()['transform'] == [{'filter': {'selection': {'or': [{'not': 's1'}, {'not': 's2'}]}}}]
+
+    chart = base.transform_filter(~(selector1 & selector2))
+    assert chart.to_dict()['transform'] == [{'filter': {'selection': {'not': {'and': ['s1', 's2']}}}}]        
+
 
 
 def test_resolve_methods():