Repository: bokeh/bokeh

Issue:
Explicitly warn that CDSView is unsupported on line glyphs
Bokeh: 1.3.4
Python: 3.6.8
OS: GNU/Linux x86_64
Browser: Firefox 70.0.1/Chromium 78.0.3904.70

Figures containing line glyphs generated from the same ColumnDataSource with different CDSViews share the same initial y axis range (however, they pan and zoom independently).
This is not the case for other plot types like circles, areas, bars (independent y range adapted to data ranges for each figure separately).
#### Expected behavior
* independent axes ranges
* consistency between glyphs





``` python
from bokeh.layouts import row
from bokeh.plotting import figure, show, output_file
from bokeh.sampledata.autompg import autompg
from bokeh.models import CDSView, GroupFilter
from bokeh.models.sources import ColumnDataSource

autompg = autompg.assign(efficient=(autompg.mpg > 20).astype(str))
autompg = autompg.groupby(['yr', 'efficient']).mpg.mean().reset_index()
autompg = autompg.sort_values(['efficient', 'yr'])
``` 
#### Example 1: Line plots and views --> produces unexpected result
``` python
list_p = []
source=ColumnDataSource(autompg)
for eff in ['True', 'False']:
    filter_ = GroupFilter(column_name='efficient', group=eff)
    view = CDSView(source=source, filters=[filter_])

    list_p.append(figure(title=eff))
    list_p[-1].line(x='yr', y='mpg', source=source, view=view)

output_file("shared_y_views.html")
show(row(list_p))
``` 
![image](https://user-images.githubusercontent.com/33124051/68474904-f9222a00-0226-11ea-8279-92ac39ea9a29.png)

#### Example 2: Circle plots and views --> produces expected result

``` python
list_p = []
source=ColumnDataSource(autompg)
for eff in ['True', 'False']:
    filter_ = GroupFilter(column_name='efficient', group=eff)
    view = CDSView(source=source, filters=[filter_])

    list_p.append(figure(title=eff))
    list_p[-1].circle(x='yr', y='mpg', source=source, view=view)

output_file("shared_y_views_circle.html")
show(row(list_p))
``` 
![image](https://user-images.githubusercontent.com/33124051/68474945-0e975400-0227-11ea-87f5-c56a09eef747.png)

#### Example 3: Line plots and separate sources --> produces expected result
``` python
# 
list_p = []
for eff in ['True', 'False']:
    source_slct = ColumnDataSource(autompg.query('efficient == "%s"'%eff))
    
    list_p.append(figure(title=eff))
    list_p[-1].line(x='yr', y='mpg', source=source_slct)

output_file("shared_y_sources.html")
show(row(list_p))
```
![image](https://user-images.githubusercontent.com/33124051/68474922-03dcbf00-0227-11ea-8747-f7724f20e40c.png)





Discussion:
I'm not actually sure that CDSView together with lines can be considered supported at present. AFAIK the behavior is not well-defined or ideal in the presence of a connected topology. Ideally subsetting line data would result in multiple disjoint polylines anywhere the view leaves contiguous groups of points, split wherever points were excluded in between the groups. It definitely doesn't do that now, and if it did you'd also be left with the question of what to do with isolated points. So to the extent it does anything now, I think it just draws a single polyline through all remaining points, whether they were adjacent originally or not. I would regard this as accidental and not explicitly intentional, as that is (IMO) a questionable thing to do from a visualization perspective. 

FWIW in the particular example above, I think line is not a good choice since this is not time series data (model years are discrete and categorical, really) and lines imply intermediate values that simply do not exist. Using views as they are now, only compounds that problem. If views behaved ideally on lines as I described I don't think any lines woudl get drawn at all (all the points would be isolated). A Scatter, Dot Plot, or Bar Graph would all be improvements here (that would also function with views).

If you really want lines in this case I am inclined to say that it's better to extract the subsets you want (in Python) and call `line` separately with those concrete subsets, rather than use views. 

I'm fairly inclined to punt on this, or if any action is taken at all, to make Bokeh warn or error when views are used with lines, stating clearly that this combination is unsupported. 

cc @bokeh/dev for thoughts


FWIW, I would definitely prefer an error or a warning here.

Also it may be worth pointing out that if a user wants to change views/filters via some UI controls in order to toggle some lines' visibility, it would probably make sense to use `multi_line` instead of `line`.
I understand that CDSView combined with lines has some (conceptual) issues, as discussed in issue #7070. In fact, this is why I need the `autompg.sort_values(['efficient', 'yr'])` step in my first example.
If the x-axis values were disconnected in the input CDS, only a single line segment would be drawn. As a user, I'm ok with how this behaves now (after figuring it out), it's really just the y-range I'm struggling with.

Concerning the appropriateness of line plots, they provide the greatest visual clarity for exploratory data analysis in my use case.

@p-himik I see that multi_lines is indeed able to produce the plots I'm looking for, using views, with independent y-ranges. However, the structure of the CDS needs to be different. This would inhibit having common CDS and views (in a parent class) used for different plot types (in various child classes).

For example, a CDS like
``` python
{'value0': [4, 6, 7, 0.8, 0.2, 0.3],
 'value1': [4, 5, 6, 0.7, 0.8, 0.4],
 'x': [0, 1, 2, 0, 1, 2],
 'subplot': ['left', 'left', 'left', 'right', 'right', 'right']}
```
can be used for `figure.line`, `figure.varea_stack`, etc. In contrast, `figure.multi_lines` would require something like

``` python
{'value': [[4, 6, 7], [0.8, 0.2, 0.3], [4, 5, 6], [0.7, 0.8, 0.4]],
 'x': [[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]],
 'color': ['blue', 'blue', 'red', 'red'],
 'subplot': ['left', 'right', 'left', 'right']}
```

TL;DR I'm really just looking for a way to have flexible plot types with the same underlying CDS structure and identical behavior, e.g. in terms of y-ranges. Otherwise, the second-best approach seem to be independent CDS's per subplot without views.

> As a user, I'm ok with how this behaves now (after figuring it out)

Right in this particular case it happens to do something ok-ish (or at least close to what you want). But I think that is a coincidence and not true in general, which means leaving things in a quasi-supported state only invites support issues. I'd rather be unambiguous (with a warning/error) that this combination is simply not supported. 

Given that, I agree that:

>  the best approach seem to be independent CDS's per subplot without views.



Resolving patch:
diff --git a/bokeh/core/validation/errors.py b/bokeh/core/validation/errors.py
--- a/bokeh/core/validation/errors.py
+++ b/bokeh/core/validation/errors.py
@@ -80,6 +80,15 @@
 1021 *(EQUAL_SLIDER_START_END)*
     ``noUiSlider`` most have a nonequal start and end.
 
+1022 *(MIN_PREFERRED_MAX_WIDTH)*
+    Expected min_width <= width <= max_width
+
+1023 *(MIN_PREFERRED_MAX_HEIGHT)*
+    Expected min_height <= height <= max_height
+
+1024 *(CDSVIEW_FILTERS_WITH_CONNECTED)*
+    ``CDSView`` filters are not compatible with glyphs with connected topology suchs as Line and Patch.
+
 9999 *(EXT)*
     Indicates that a custom error check has failed.
 
@@ -121,8 +130,9 @@
     1019: ("DUPLICATE_FACTORS",                                  "FactorRange must specicy a unique list of categorical factors for an axis"),
     1020: ("BAD_EXTRA_RANGE_NAME",                               "An extra range name is configued with a name that does not correspond to any range"),
     1021: ("EQUAL_SLIDER_START_END",                             "Slider 'start' and 'end' cannot be equal"),
-    1022: ("MIN_PREFERRED_MAX_WIDTH",                            "expected min_width <= width <= max_width"),
-    1023: ("MIN_PREFERRED_MAX_HEIGHT",                           "expected min_height <= height <= max_height"),
+    1022: ("MIN_PREFERRED_MAX_WIDTH",                            "Expected min_width <= width <= max_width"),
+    1023: ("MIN_PREFERRED_MAX_HEIGHT",                           "Expected min_height <= height <= max_height"),
+    1024: ("CDSVIEW_FILTERS_WITH_CONNECTED",                     "CDSView filters are not compatible with glyphs with connected topology suchs as Line and Patch"),
     9999: ("EXT",                                                "Custom extension reports error"),
 }
 
diff --git a/bokeh/models/glyphs.py b/bokeh/models/glyphs.py
--- a/bokeh/models/glyphs.py
+++ b/bokeh/models/glyphs.py
@@ -136,6 +136,13 @@ class XYGlyph(Glyph):
 
     '''
 
+@abstract
+class ConnectedXYGlyph(XYGlyph):
+    ''' Base class of glyphs with `x` and `y` coordinate attributes and
+    a connected topology.
+
+    '''
+
 class AnnularWedge(XYGlyph):
     ''' Render annular wedges.
 
@@ -688,7 +695,7 @@ class ImageURL(XYGlyph):
     specified URL. Default is zero ms.
     """)
 
-class Line(XYGlyph):
+class Line(ConnectedXYGlyph):
     ''' Render a single line.
 
     The ``Line`` glyph is different from most other glyphs in that the vector
@@ -826,7 +833,7 @@ class Oval(XYGlyph):
     The %s values for the ovals.
     """)
 
-class Patch(XYGlyph):
+class Patch(ConnectedXYGlyph):
     ''' Render a single patch.
 
     The ``Patch`` glyph is different from most other glyphs in that the vector
diff --git a/bokeh/models/renderers.py b/bokeh/models/renderers.py
--- a/bokeh/models/renderers.py
+++ b/bokeh/models/renderers.py
@@ -37,13 +37,14 @@
 from ..core.validation import error
 from ..core.validation.errors import (
     BAD_COLUMN_NAME,
+    CDSVIEW_FILTERS_WITH_CONNECTED,
     CDSVIEW_SOURCE_DOESNT_MATCH,
     MALFORMED_GRAPH_SOURCE,
     MISSING_GLYPH,
     NO_SOURCE_FOR_GLYPH,
 )
 from ..model import Model
-from .glyphs import Circle, Glyph, MultiLine
+from .glyphs import Circle, ConnectedXYGlyph, Glyph, MultiLine
 from .graphs import GraphHitTestPolicy, LayoutProvider, NodesOnly
 from .sources import CDSView, ColumnDataSource, DataSource, WebSource
 from .tiles import TileSource, WMTSTileSource
@@ -127,6 +128,14 @@ class GlyphRenderer(DataRenderer):
 
     '''
 
+    def __str__(self):
+        return f"GlyphRenderer(id={self.id}, glyph={str(self.glyph)}, ...)"
+
+    @error(CDSVIEW_FILTERS_WITH_CONNECTED)
+    def _check_cdsview_filters_with_connected(self):
+        if isinstance(self.glyph, ConnectedXYGlyph) and len(self.view.filters) > 0:
+            return str(self)
+
     @error(MISSING_GLYPH)
     def _check_missing_glyph(self):
         if not self.glyph: return str(self)
@@ -172,6 +181,11 @@ def __init__(self, **kw):
     A view into the data source to use when rendering glyphs. A default view
     of the entire data source is created when a view is not passed in during
     initialization.
+
+    .. note:
+        Only the default (filterless) CDSView is compatible with glyphs that
+        have connected topology, such as Line and Patch. Setting filters on
+        views for these glyphs will result in a warning and undefined behavior.
     """)
 
     glyph = Instance(Glyph, help="""


Test patch:
diff --git a/tests/unit/bokeh/models/test_renderers.py b/tests/unit/bokeh/models/test_renderers.py
--- a/tests/unit/bokeh/models/test_renderers.py
+++ b/tests/unit/bokeh/models/test_renderers.py
@@ -15,10 +15,10 @@
 #-----------------------------------------------------------------------------
 
 # Bokeh imports
-from bokeh.models import Circle, ColumnDataSource, MultiLine
+from bokeh.models import Circle, ColumnDataSource, IndexFilter, Line, MultiLine, Patch
 
 # Module under test
-from bokeh.models.renderers import GlyphRenderer, GraphRenderer # isort:skip
+import bokeh.models.renderers as bmr # isort:skip
 
 #-----------------------------------------------------------------------------
 # Setup
@@ -28,35 +28,51 @@
 # General API
 #-----------------------------------------------------------------------------
 
-def test_graphrenderer_init_props():
-    renderer = GraphRenderer()
-    assert renderer.x_range_name == "default"
-    assert renderer.y_range_name == "default"
-    assert renderer.node_renderer.data_source.data == dict(index=[])
-    assert renderer.edge_renderer.data_source.data == dict(start=[], end=[])
-    assert renderer.layout_provider is None
+class TestGlyphRenderer(object):
 
-def test_graphrenderer_check_malformed_graph_source_no_errors():
-    renderer = GraphRenderer()
+    @pytest.mark.parametrize('glyph', [Line, Patch])
+    def test_check_cdsview_filters_with_connected_error(self, glyph):
+        renderer = bmr.GlyphRenderer()
+        renderer.glyph = glyph()
 
-    check = renderer._check_malformed_graph_source()
-    assert check == []
+        check = renderer._check_cdsview_filters_with_connected()
+        assert check == []
 
-def test_graphrenderer_check_malformed_graph_source_no_node_index():
-    node_source = ColumnDataSource()
-    node_renderer = GlyphRenderer(data_source=node_source, glyph=Circle())
-    renderer = GraphRenderer(node_renderer=node_renderer)
+        renderer.view.filters = [IndexFilter()]
+        check = renderer._check_cdsview_filters_with_connected()
+        assert check != []
 
-    check = renderer._check_malformed_graph_source()
-    assert check != []
+class TestGraphRenderer(object):
 
-def test_graphrenderer_check_malformed_graph_source_no_edge_start_or_end():
-    edge_source = ColumnDataSource()
-    edge_renderer = GlyphRenderer(data_source=edge_source, glyph=MultiLine())
-    renderer = GraphRenderer(edge_renderer=edge_renderer)
+    def test_init_props(self):
+        renderer = bmr.GraphRenderer()
+        assert renderer.x_range_name == "default"
+        assert renderer.y_range_name == "default"
+        assert renderer.node_renderer.data_source.data == dict(index=[])
+        assert renderer.edge_renderer.data_source.data == dict(start=[], end=[])
+        assert renderer.layout_provider is None
 
-    check = renderer._check_malformed_graph_source()
-    assert check != []
+    def test_check_malformed_graph_source_no_errors(self):
+        renderer = bmr.GraphRenderer()
+
+        check = renderer._check_malformed_graph_source()
+        assert check == []
+
+    def test_check_malformed_graph_source_no_node_index(self):
+        node_source = ColumnDataSource()
+        node_renderer = bmr.GlyphRenderer(data_source=node_source, glyph=Circle())
+        renderer = bmr.GraphRenderer(node_renderer=node_renderer)
+
+        check = renderer._check_malformed_graph_source()
+        assert check != []
+
+    def test_check_malformed_graph_source_no_edge_start_or_end(self):
+        edge_source = ColumnDataSource()
+        edge_renderer = bmr.GlyphRenderer(data_source=edge_source, glyph=MultiLine())
+        renderer = bmr.GraphRenderer(edge_renderer=edge_renderer)
+
+        check = renderer._check_malformed_graph_source()
+        assert check != []
 
 #-----------------------------------------------------------------------------
 # Dev API