Repository: bokeh/bokeh

Issue:
Task: Graph visualization improvements
based on #6544 

Needs:

- [x] user guide documentation
- [x] support for tool interactions
- [x] add "Examples" example
- [x] add GraphRenderer.hit_test tests
- [x] have GraphRenderer figure into Plot.bounds (won't fix)
- [x] figure out GraphHitTestPolicy NodesAndLinkedEdges.do_inspection test issue (they're commented out)
- [x] Add EdgesAndLinkedNodes policy
- [x] Add Figure.graph method and handle kwargs
- [ ] update TS API


Discussion:
```python
"""
Zachary's Karate Club graph

Data file from:
http://vlado.fmf.uni-lj.si/pub/networks/data/Ucinet/UciData.htm

Reference:
Zachary W. (1977).
An information flow model for conflict and fission in small groups.
Journal of Anthropological Research, 33, 452-473.
"""

import networkx as nx

from bokeh.io import show, curdoc
from bokeh.layouts import Row, Column
from bokeh.models import Plot, Title, Range1d, GraphSource, MultiLine, Circle, Rect, Ellipse, Text
from bokeh.palettes import Spectral4


PLOT_OPTS = dict(
    toolbar_location=None,
    plot_width=400,
    plot_height=400,
    x_range=Range1d(-1.1,1.1), 
    y_range=Range1d(-1.1,1.1),
)

EDGE_OPTS = dict(
    line_color="#CCCCCC",
    line_alpha=0.8
)

G=nx.karate_club_graph()

graph_source1 = GraphSource.from_networkx(G, nx.circular_layout, scale=1, center=(0,0))
p1 = Plot(title=Title(text="Circular Layout"), **PLOT_OPTS)
p1.add_graph(graph_source1, nodes=Circle(size=15, fill_color=Spectral4[0]), edges=MultiLine(**EDGE_OPTS))

graph_source2 = GraphSource.from_networkx(G, nx.random_layout, scale=2, center=(0,0))
p2 = Plot(title=Title(text="Random Layout"), **PLOT_OPTS)
p2.add_graph(graph_source2, nodes=Rect(width=0.05, height=0.05, fill_color=Spectral4[1]), edges=MultiLine(**EDGE_OPTS))

graph_source3 = GraphSource.from_networkx(G, nx.fruchterman_reingold_layout, scale=2, center=(0,0), dim=2)
p3 = Plot(title=Title(text="Fruchterman Reingold Layout"), **PLOT_OPTS)
p3.add_graph(graph_source3, nodes=Text(text='index', text_color=Spectral4[2], text_baseline="middle", text_align="center"), edges=MultiLine(**EDGE_OPTS))

graph_source4 = GraphSource.from_networkx(G, nx.spectral_layout, scale=2, center=(0,0))
p4 = Plot(title=Title(text="Spectral Layout"), **PLOT_OPTS)
p4.add_graph(graph_source4, nodes=Ellipse(width=0.08, height=0.04, fill_color=Spectral4[3]), edges=MultiLine(**EDGE_OPTS))

layout = Column(Row(p1, p2), Row(p3, p4))

doc = curdoc()
doc.add_root(layout)

show(layout)
```

Resolving patch:
diff --git a/bokeh/models/graphs.py b/bokeh/models/graphs.py
--- a/bokeh/models/graphs.py
+++ b/bokeh/models/graphs.py
@@ -1,10 +1,8 @@
-from .sources import ColumnDataSource
-
 from ..core.has_props import abstract
-from ..core.properties import Any, Dict, Either, Instance, Int, Seq, String
-from ..core.validation import error
-from ..core.validation.errors import MALFORMED_GRAPH_SOURCE
+from ..core.properties import Any, Dict, Either, Int, Seq, String
 from ..model import Model
+from ..models.sources import ColumnDataSource
+
 
 @abstract
 class LayoutProvider(Model):
@@ -33,22 +31,9 @@ class StaticLayoutProvider(LayoutProvider):
         }
     """)
 
-
-_DEFAULT_NODE_SOURCE = lambda: ColumnDataSource(data=dict(index=[]))
-
-
-_DEFAULT_EDGE_SOURCE = lambda: ColumnDataSource(data=dict(start=[], end=[]))
-
-
-class GraphSource(Model):
-    '''
-
-    '''
-
-    @classmethod
-    def from_networkx(cls, graph, layout_function, **kwargs):
+def from_networkx(graph, layout_function, **kwargs):
         '''
-        Generate a GraphSource from a networkx.Graph object and networkx
+        Generate a GraphRenderer from a networkx.Graph object and networkx
         layout function. Any keyword arguments will be passed to the
         layout function.
 
@@ -57,13 +42,17 @@ def from_networkx(cls, graph, layout_function, **kwargs):
             layout_function (function) : a networkx layout function
 
         Returns:
-            instance (GraphSource)
+            instance (GraphRenderer)
 
         .. warning::
             Only two dimensional layouts are currently supported.
 
         '''
 
+        # inline import to prevent circular imports
+        from ..models.renderers import GraphRenderer
+        from ..models.graphs import StaticLayoutProvider
+
         nodes = graph.nodes()
         edges = graph.edges()
         edges_start = [edge[0] for edge in edges]
@@ -75,46 +64,11 @@ def from_networkx(cls, graph, layout_function, **kwargs):
             end=edges_end
         ))
 
-        graph_layout = layout_function(graph, **kwargs)
-        layout_provider = StaticLayoutProvider(graph_layout=graph_layout)
-
-        return cls(nodes=node_source, edges=edge_source, layout_provider=layout_provider)
-
-    @error(MALFORMED_GRAPH_SOURCE)
-    def _check_malformed_graph_source(self):
-        missing = []
-        if "index" not in self.nodes.column_names:
-            missing.append("Column 'index' is missing in GraphSource.nodes source")
-        if "start" not in self.edges.column_names:
-            missing.append("Column 'start' is missing in GraphSource.edges source")
-        if "end" not in self.edges.column_names:
-            missing.append("Column 'end' is missing in GraphSource.edges source")
-
-        indexes = self.nodes.data.get('index', [])
-        for i in self.edges.data.get('start', []):
-            if i not in indexes:
-                missing.append("GraphSource.edge 'start' value '%s' missing from GraphSource.nodes 'index' column" % i)
-
-        for i in self.edges.data.get('end', []):
-            if i not in indexes:
-                missing.append("GraphSource.edge 'end' value '%s' missing from GraphSource.nodes 'index' column" % i)
-
-        if missing:
-            return " ,".join(missing) + " [%s]" % self
-
-    nodes = Instance(ColumnDataSource, default=_DEFAULT_NODE_SOURCE, help="""
-    A ColumnDataSource corresponding the graph nodes. It's required that the
-    source have a column named ``index``, which contain the lookup values of
-    the nodes.
-    """)
+        graph_renderer = GraphRenderer()
+        graph_renderer.node_renderer.data_source.data = node_source.data
+        graph_renderer.edge_renderer.data_source.data = edge_source.data
 
-    edges = Instance(ColumnDataSource, default=_DEFAULT_EDGE_SOURCE, help="""
-    A ColumnDataSource corresponding to the graph edges. It's required that the
-    source have two columns named ``start`` and ``edge``, which contain the
-    start and edge nodes of an edge.
-    """)
+        graph_layout = layout_function(graph, **kwargs)
+        graph_renderer.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)
 
-    layout_provider = Instance(LayoutProvider, help="""
-    An instance of a LayoutProvider that supplies the layout of the network
-    graph in cartesian space.
-    """)
+        return graph_renderer
diff --git a/bokeh/models/renderers.py b/bokeh/models/renderers.py
--- a/bokeh/models/renderers.py
+++ b/bokeh/models/renderers.py
@@ -11,12 +11,13 @@
 from ..core.has_props import abstract
 from ..core.properties import Auto, Bool, Either, Enum, Float, Instance, Override, String
 from ..core.validation import error
-from ..core.validation.errors import BAD_COLUMN_NAME, MISSING_GLYPH, NO_SOURCE_FOR_GLYPH, CDSVIEW_SOURCE_DOESNT_MATCH
+from ..core.validation.errors import (BAD_COLUMN_NAME, MISSING_GLYPH, NO_SOURCE_FOR_GLYPH,
+                                      CDSVIEW_SOURCE_DOESNT_MATCH, MALFORMED_GRAPH_SOURCE)
 from ..model import Model
 from ..util.deprecation import deprecated
 
-from .glyphs import Glyph, XYGlyph, Circle, MultiLine
-from .graphs import GraphSource
+from .glyphs import Glyph, Circle, MultiLine
+from .graphs import LayoutProvider
 from .images import ImageSource
 from .sources import ColumnDataSource, DataSource, RemoteSource, CDSView
 from .tiles import TileSource, WMTSTileSource
@@ -37,7 +38,7 @@ class Renderer(Model):
 
 @abstract
 class DataRenderer(Renderer):
-    ''' An abstract base class for data renderer types (e.g. ``GlyphRenderer``, ``TileRenderer``).
+    ''' An abstract base class for data renderer types (e.g. ``GlyphRenderer``, ``TileRenderer``, ``GraphRenderer``).
 
     '''
 
@@ -189,42 +190,56 @@ def __init__(self, **kw):
 
     level = Override(default="glyph")
 
-_DEFAULT_NODE = lambda: Circle()
+_DEFAULT_NODE_RENDERER = lambda: GlyphRenderer(
+    glyph=Circle(), data_source=ColumnDataSource(data=dict(index=[]))
+)
 
-_DEFAULT_EDGE = lambda: MultiLine()
+_DEFAULT_EDGE_RENDERER = lambda: GlyphRenderer(
+    glyph=MultiLine(), data_source=ColumnDataSource(data=dict(start=[], end=[]))
+)
 
 class GraphRenderer(DataRenderer):
     '''
 
     '''
 
+    @error(MALFORMED_GRAPH_SOURCE)
+    def _check_malformed_graph_source(self):
+        missing = []
+        if "index" not in self.node_renderer.data_source.column_names:
+            missing.append("Column 'index' is missing in GraphSource.node_renderer.data_source")
+        if "start" not in self.edge_renderer.data_source.column_names:
+            missing.append("Column 'start' is missing in GraphSource.edge_renderer.data_source")
+        if "end" not in self.edge_renderer.data_source.column_names:
+            missing.append("Column 'end' is missing in GraphSource.edge_renderer.data_source")
+        if missing:
+            return " ,".join(missing) + " [%s]" % self
+
     x_range_name = String('default', help="""
     A particular (named) x-range to use for computing screen
-    locations when rendering glyphs on the plot. If unset, use the
+    locations when rendering graphs on the plot. If unset, use the
     default x-range.
     """)
 
     y_range_name = String('default', help="""
     A particular (named) y-range to use for computing screen
-    locations when rendering glyphs on the plot. If unset, use the
-    default y-range.
+    locations when rendering graphs on the plot. If unset, use the
+    default -range.
     """)
 
-    graph_source = Instance(GraphSource, help="""
-    Instance of a GraphSource which contains the information about the
-    nodes and edges of a network graph.
+    layout_provider = Instance(LayoutProvider, help="""
+    An instance of a LayoutProvider that supplies the layout of the network
+    graph in cartesian space.
     """)
 
-    nodes = Instance(XYGlyph, default=_DEFAULT_NODE, help="""
-    Instance of an XYGlyph subclass that will be rendered as the graph nodes.
-    The marker attribute fields will be matched against columns in the
-    ``graph_source.nodes`` ColumnDataSource.
+    node_renderer = Instance(GlyphRenderer, default=_DEFAULT_NODE_RENDERER, help="""
+    Instance of a GlyphRenderer containing an XYGlyph that will be rendered
+    as the graph nodes.
     """)
 
-    edges = Instance(MultiLine, default=_DEFAULT_EDGE, help="""
-    Instance of a MultiLine glyph that will be rendered as the graph edges. The
-    multiline attribute fields will be matched against columns in the
-    ``graph_source.edges`` ColumnDataSource.
+    edge_renderer = Instance(GlyphRenderer, default=_DEFAULT_EDGE_RENDERER, help="""
+    Instance of a GlyphRenderer containing an MultiLine Glyph that will be
+    rendered as the graph edges.
     """)
 
     level = Override(default="glyph")


Test patch:
diff --git a/bokeh/models/tests/test_graphs.py b/bokeh/models/tests/test_graphs.py
--- a/bokeh/models/tests/test_graphs.py
+++ b/bokeh/models/tests/test_graphs.py
@@ -1,83 +1,34 @@
 import networkx as nx
 import numpy as np
 
-from bokeh.models.graphs import GraphSource, StaticLayoutProvider
-from bokeh.models.sources import ColumnDataSource
+from bokeh.models.graphs import StaticLayoutProvider, from_networkx
+
 
 def test_staticlayoutprovider_init_props():
     provider = StaticLayoutProvider()
     assert provider.graph_layout == {}
 
-def test_graphsource_init_props():
-    source = GraphSource()
-    assert source.nodes.data == dict(index=[])
-    assert source.edges.data == dict(start=[], end=[])
-    assert source.layout_provider is None
-
-def test_graphsource_check_malformed_graph_source_no_errors():
-    source = GraphSource()
-
-    check = source._check_malformed_graph_source()
-    assert check == []
-
-def test_graphsource_check_malformed_graph_source_no_node_index():
-    node_source = ColumnDataSource()
-    source = GraphSource(nodes=node_source)
-
-    check = source._check_malformed_graph_source()
-    assert check != []
-
-def test_graphsource_check_malformed_graph_source_no_edge_start_or_end():
-    edge_source = ColumnDataSource()
-    source = GraphSource(edges=edge_source)
-
-    check = source._check_malformed_graph_source()
-    assert check != []
-
-def test_graphsource_check_malformed_missing_index_in_start():
-    edge_source = ColumnDataSource(data=dict(
-        start=[1,2,5],
-        end=[1,2,3]))
-    node_source = ColumnDataSource(data=dict(index=[1,2,3]))
-    source = GraphSource(nodes=node_source, edges=edge_source)
-
-    check = source._check_malformed_graph_source()
-    assert check != []
-
-def test_graphsource_check_malformed_missing_index_in_end():
-    edge_source = ColumnDataSource(data=dict(
-        start=[1,2,3],
-        end=[1,2,6]))
-    node_source = ColumnDataSource(data=dict(index=[1,2,3]))
-    source = GraphSource(nodes=node_source, edges=edge_source)
-
-    check = source._check_malformed_graph_source()
-    assert check != []
-
-def test_graphsource_from_networkx():
+def test_from_networkx_method():
     G=nx.Graph()
     G.add_nodes_from([0,1,2,3])
     G.add_edges_from([[0,1], [0,2], [2,3]])
 
-    source = GraphSource.from_networkx(G, nx.circular_layout)
-    assert source.nodes.data["index"] == [0,1,2,3]
-    assert source.edges.data["start"] == [0,0,2]
-    assert source.edges.data["end"] == [1,2,3]
+    renderer = from_networkx(G, nx.circular_layout)
+    assert renderer.node_renderer.data_source.data["index"] == [0,1,2,3]
+    assert renderer.edge_renderer.data_source.data["start"] == [0,0,2]
+    assert renderer.edge_renderer.data_source.data["end"] == [1,2,3]
 
-    gl = source.layout_provider.graph_layout
+    gl = renderer.layout_provider.graph_layout
     assert set(gl.keys()) == set([0,1,2,3])
     assert np.array_equal(gl[0], np.array([1., 0.]))
 
-def test_graphsource_from_networkx_with_kwargs():
+def test_from_networkx_method_with_kwargs():
     G=nx.Graph()
     G.add_nodes_from([0,1,2,3])
     G.add_edges_from([[0,1], [0,2], [2,3]])
 
-    source = GraphSource.from_networkx(G, nx.circular_layout, scale=2)
-    assert source.nodes.data["index"] == [0,1,2,3]
-    assert source.edges.data["start"] == [0,0,2]
-    assert source.edges.data["end"] == [1,2,3]
+    renderer = from_networkx(G, nx.circular_layout, scale=2)
 
-    gl = source.layout_provider.graph_layout
+    gl = renderer.layout_provider.graph_layout
     assert set(gl.keys()) == set([0,1,2,3])
     assert np.array_equal(gl[0], np.array([2., 0.]))
diff --git a/bokeh/models/tests/test_renderers.py b/bokeh/models/tests/test_renderers.py
--- a/bokeh/models/tests/test_renderers.py
+++ b/bokeh/models/tests/test_renderers.py
@@ -3,7 +3,9 @@
 import unittest
 
 from bokeh.plotting import figure
+from bokeh.models import Circle, MultiLine, ColumnDataSource
 from bokeh.models.ranges import DataRange1d
+from bokeh.models.renderers import GraphRenderer, GlyphRenderer
 
 class TestGlyphRenderer(unittest.TestCase):
     def test_warning_about_colons_in_column_labels_for_axis(self):
@@ -44,5 +46,32 @@ def test_validates_colons_only_in_factorial_range(self):
             '[renderer: Figure(id=%r, ...)]' % plot._id
         )])
 
-if __name__ == '__main__':
-    unittest.main()
+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
+
+def test_graphrenderer_check_malformed_graph_source_no_errors():
+    renderer = GraphRenderer()
+
+    check = renderer._check_malformed_graph_source()
+    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)
+
+    check = renderer._check_malformed_graph_source()
+    assert check != []
+
+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)
+
+    check = renderer._check_malformed_graph_source()
+    assert check != []
diff --git a/bokehjs/test/core/index.ts b/bokehjs/test/core/index.ts
--- a/bokehjs/test/core/index.ts
+++ b/bokehjs/test/core/index.ts
@@ -6,6 +6,5 @@ import "./logging"
 import "./properties"
 import "./property_mixins"
 import "./util"
-import "./selection_manager"
 import "./selector"
 import "./ui_events"
diff --git a/bokehjs/test/core/selection_manager.coffee b/bokehjs/test/core/selection_manager.coffee
deleted file mode 100644
--- a/bokehjs/test/core/selection_manager.coffee
+++ /dev/null
@@ -1,176 +0,0 @@
-{expect} = require "chai"
-utils = require "../utils"
-
-{SelectionManager} = utils.require "core/selection_manager"
-
-SomeMarker = utils.require("models/markers/index").CircleX
-NullMarker = utils.require("models/glyphs/index").MultiLine
-{GlyphRenderer} = utils.require("models/renderers/glyph_renderer")
-{GlyphRendererView} = utils.require("models/renderers/glyph_renderer")
-{ColumnDataSource} = utils.require("models/sources/column_data_source")
-{LinearScale} = utils.require("models/scales/linear_scale")
-{Range1d} = utils.require("models/ranges/range1d")
-
-hittest = utils.require "core/hittest"
-empty_selection = hittest.create_hit_test_result()
-
-describe "SelectionManager", ->
-
-  source_normal = {start: 0, end: 10}
-  source_reverse = {start: 10, end: 0}
-  target = {start: 0, end: 100}
-
-  scale_normal = new LinearScale({
-    source_range: new Range1d(source_normal)
-    target_range: new Range1d(target)
-  })
-
-  scale_reverse = new LinearScale({
-    source_range: new Range1d(source_reverse)
-    target_range: new Range1d(target)
-  })
-
-  # The objects defined below to stub out the Plot and PlotView depend on the current interfaces
-  # of Plot and PlotView and the specific implementation of GlyphRenderer and GlyphRendererView
-  # initialization. For example, the GlyphRendererView defines its x and y scale through:
-
-  # @xscale = @plot_view.frame.xscales[@model.x_range_name]
-  # @yscale = @plot_view.frame.yscales[@model.y_range_name]
-
-  # so we put the scales (scale_normal and scale_reverse, defined above)
-  # in the stub objects in the same "location".
-
-  plot_model_stub = {
-    use_map: false
-    lod_factor: 1
-    plot: {lod_factor: 0.1}
-  }
-
-  plot_view_stub_normal = {
-    frame:
-      'xscales':
-        {'default': scale_normal}
-      'yscales':
-        {'default': scale_normal}
-    canvas_view:
-      ctx: {}
-    model:
-      plot_model_stub
-  }
-
-  plot_view_stub_reverse = {
-    frame:
-      'xscales':
-        {'default': scale_reverse}
-      'yscales':
-        {'default': scale_reverse}
-    canvas_view:
-      ctx: {}
-    model:
-      plot_model_stub
-  }
-
-  column_data_source = new ColumnDataSource({
-    data:
-      x: [0, 1, 2, 3, 4]
-      y: [0, 1, 2, 3, 4]
-      z: [5, 6, 7, 8, 9]
-  })
-
-  glyph_renderer = new GlyphRenderer({
-    data_source:  column_data_source
-    glyph:        new SomeMarker({x: {field: "x"}, y: {field: "y"}})
-  })
-
-  glyph_renderer_view_normal = new GlyphRendererView({
-    plot_model: plot_model_stub
-    plot_view: plot_view_stub_normal
-    model: glyph_renderer
-  })
-
-  glyph_renderer_view_reverse = new GlyphRendererView({
-    plot_model: plot_model_stub
-    plot_view: plot_view_stub_reverse
-    model: glyph_renderer
-  })
-
-  glyph_renderer2 = new GlyphRenderer({
-    data_source:  column_data_source
-    glyph:        new SomeMarker({x: {field: "x"}, y: {field: "z"}})
-  })
-
-  glyph_renderer_view2 = new GlyphRendererView({
-    plot_model: plot_model_stub
-    plot_view: plot_view_stub_normal
-    model: glyph_renderer2
-  })
-
-  column_data_source_null = new ColumnDataSource({
-    data:
-      x: [0, 1, 2, 3, 4]
-      y: [0, 1, 2, 3, 4]
-      z: [5, 6, 7, 8, 9]
-  })
-
-  glyph_renderer_null = new GlyphRenderer({
-    data_source:  column_data_source_null
-    glyph:        new NullMarker({xs: {field: "x"}, ys: {field: "y"}})
-  })
-
-  glyph_renderer_view_null = new GlyphRendererView({
-    plot_model: plot_model_stub
-    plot_view: plot_view_stub_normal
-    model: glyph_renderer_null
-  })
-
-  sm = new SelectionManager()
-
-  describe "using rect geometry (like that from box select tool)", ->
-
-    geometry = {
-      type: 'rect'
-      vx0: 0
-      vx1: 100
-      vy0: 0
-      vy1: 100
-    }
-
-    it "should update its data source's selected property", ->
-      sm = new SelectionManager({'source': column_data_source})
-      expect(sm.source.selected).to.deep.equal empty_selection
-      sm.select('tool', [glyph_renderer_view_normal], geometry, true)
-      expect(sm.source.selected).to.not.deep.equal empty_selection
-
-    it "should update its selector", ->
-      sm = new SelectionManager({'source': column_data_source})
-      sm.select('tool', [glyph_renderer_view_normal], geometry, true)
-      expect(sm.selector).to.not.deep.equal empty_selection
-
-    it "should work when scales are reversed", ->
-      sm = new SelectionManager({'source': column_data_source})
-      sm.select('tool', [glyph_renderer_view_reverse], geometry, true)
-      expect(sm.source.selected).to.not.deep.equal empty_selection
-      expect(sm.selector).to.not.deep.equal empty_selection
-
-    geometry2 = {
-      type: 'rect'
-      vx0: 0
-      vx1: 100
-      vy0: 60
-      vy1: 100
-    }
-
-    it "should select from multiple renderers", ->
-      sm = new SelectionManager({'source': column_data_source})
-      sm.select('tool', [glyph_renderer_view_normal], geometry2, true)
-      expect(sm.source.selected).to.deep.equal empty_selection
-      sm.select('tool', [glyph_renderer_view_normal, glyph_renderer_view2], geometry2, true)
-      expect(sm.source.selected).not.to.deep.equal empty_selection
-
-    # TODO (bev) if hit tests are changed to uniformly return lists, this can be removed
-    it "should ignore null hit test results", ->
-      sm = new SelectionManager({'source': column_data_source_null})
-      expect(sm.source.selected).to.deep.equal empty_selection
-      r = sm.select('tool', [glyph_renderer_view_null], geometry, true)
-      expect(r).to.be.false
-      expect(sm.source.selected).to.deep.equal empty_selection
diff --git a/bokehjs/test/defaults.ts b/bokehjs/test/defaults.ts
--- a/bokehjs/test/defaults.ts
+++ b/bokehjs/test/defaults.ts
@@ -61,8 +61,12 @@ function check_matching_defaults(name: string, python_defaults: {[key: string]:
   for (const k in bokehjs_defaults) {
     const js_v = bokehjs_defaults[k]
 
-    // special case for graph renderer sources
-    if (name === "GraphRenderer" && k === "graph_source")
+    // special case for graph renderer node_renderer
+    if (name === "GraphRenderer" && k === "node_renderer")
+      continue
+
+    // special case for graph renderer node_renderer
+    if (name === "GraphRenderer" && k === "edge_renderer")
       continue
 
     // special case for date picker, default is "now"
diff --git a/bokehjs/test/models/graphs/static_layout_provider.coffee b/bokehjs/test/models/graphs/static_layout_provider.coffee
--- a/bokehjs/test/models/graphs/static_layout_provider.coffee
+++ b/bokehjs/test/models/graphs/static_layout_provider.coffee
@@ -2,7 +2,7 @@
 utils = require "../../utils"
 
 {StaticLayoutProvider} = utils.require("models/graphs/static_layout_provider")
-{GraphSource} = utils.require("models/graphs/graph_source")
+{ColumnDataSource} = utils.require("models/sources/column_data_source")
 
 describe "StaticLayoutProvider", ->
 
@@ -21,39 +21,37 @@ describe "StaticLayoutProvider", ->
     describe "get_node_coordinates method", ->
 
       it "should return node coords if exist", ->
-        graph_source = new GraphSource()
-        graph_source.nodes.data.index = [0,1,2,3]
+        node_source = new ColumnDataSource()
+        node_source.data.index = [0,1,2,3]
 
-        [xs, ys] = @layout_provider.get_node_coordinates(graph_source)
+        [xs, ys] = @layout_provider.get_node_coordinates(node_source)
         expect(xs).to.be.deep.equal([-1,0,1,0])
         expect(ys).to.be.deep.equal([0,1,0,-1])
 
       it "should return nulls if coords don't exist", ->
-        graph_source = new GraphSource()
-        graph_source.nodes.data.index = [4,5,6]
+        node_source = new ColumnDataSource()
+        node_source.data.index = [4,5,6]
 
-        [xs, ys] = @layout_provider.get_node_coordinates(graph_source)
+        [xs, ys] = @layout_provider.get_node_coordinates(node_source)
         expect(xs).to.be.deep.equal([null, null, null])
         expect(ys).to.be.deep.equal([null, null, null])
 
-    describe "get_node_coordinates method", ->
+    describe "get_edge_coordinates method", ->
 
       it "should return edge coords if exist", ->
-        graph_source = new GraphSource()
-        graph_source.nodes.data.index = [0,1,2,3]
-        graph_source.edges.data.start = [0,0,0]
-        graph_source.edges.data.end = [1,2,3]
+        edge_source = new ColumnDataSource()
+        edge_source.data.start = [0,0,0]
+        edge_source.data.end = [1,2,3]
 
-        [xs, ys] = @layout_provider.get_edge_coordinates(graph_source)
+        [xs, ys] = @layout_provider.get_edge_coordinates(edge_source)
         expect(xs).to.be.deep.equal([ [ -1, 0 ], [ -1, 1 ], [ -1, 0 ] ])
         expect(ys).to.be.deep.equal([ [ 0, 1 ], [ 0, 0 ], [ 0, -1 ] ])
 
       it "should return nulls if coords don't exist", ->
-        graph_source = new GraphSource()
-        graph_source.nodes.data.index = [4,5,6,7]
-        graph_source.edges.data.start = [4,4,4]
-        graph_source.edges.data.end = [5,6,7]
+        edge_source = new ColumnDataSource()
+        edge_source.data.start = [4,4,4]
+        edge_source.data.end = [5,6,7]
 
-        [xs, ys] = @layout_provider.get_edge_coordinates(graph_source)
+        [xs, ys] = @layout_provider.get_edge_coordinates(edge_source)
         expect(xs).to.be.deep.equal([ [ null, null ], [ null, null ], [ null, null ] ])
         expect(ys).to.be.deep.equal([ [ null, null ], [ null, null ], [ null, null ] ])
diff --git a/bokehjs/test/models/renderers/glyph_renderer.coffee b/bokehjs/test/models/renderers/glyph_renderer.coffee
--- a/bokehjs/test/models/renderers/glyph_renderer.coffee
+++ b/bokehjs/test/models/renderers/glyph_renderer.coffee
@@ -1,9 +1,11 @@
 {expect} = require "chai"
 utils = require "../../utils"
 
+{create_1d_hit_test_result} = utils.require("core/hittest")
+{Selector} = utils.require("core/selector")
+
 {ColumnDataSource} = utils.require("models/sources/column_data_source")
 {DataSource} = utils.require("models/sources/data_source")
-{Glyph} = utils.require("models/glyphs/glyph")
 {GlyphRenderer} = utils.require("models/renderers/glyph_renderer")
 
 describe "GlyphRenderer", ->
@@ -45,6 +47,66 @@ describe "GlyphRenderer", ->
 
   describe "hit_test_helper", ->
 
-    it "should return null if @visible is false", ->
+    ## Dummy GlyphView classes that implement `hit_test` method
+    class HitTestNotImplemented
+      hit_test: (geometry) -> return null
+
+    class HitTestMiss
+      hit_test: (geometry) -> return create_1d_hit_test_result([])
+
+    class HitTestHit
+      hit_test: (geometry) -> return create_1d_hit_test_result([0, 1])
+
+    it "should return false if @visible is false", ->
       @gr.visible = false
-      expect(@gr.hit_test_helper("junk", "junk")).to.be.equal null
+      glyph_view = new HitTestHit()
+      expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "select")).to.be.false
+
+    it "should return false if GlyphView doesn't have hit-testing and returns null", ->
+      glyph_view = new HitTestNotImplemented()
+      expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "select")).to.be.false
+
+    describe "mode='select'", ->
+
+      it "should return false and clear selections if hit_test result is empty", ->
+        initial_selection = create_1d_hit_test_result([1,2])
+        @source.selected = initial_selection
+        @source.selection_manager.selector.indices = initial_selection
+
+        glyph_view = new HitTestMiss()
+        expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "select")).to.be.false
+        indices = @source.selection_manager.selector.indices
+        expect(indices.is_empty()).to.be.true
+        expect(@source.selected.is_empty()).to.be.true
+
+      it "should return true if hit_test result is not empty", ->
+        glyph_view = new HitTestHit()
+        expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "select")).to.be.true
+        indices = @source.selection_manager.selector.indices
+        expect(indices.is_empty()).to.be.false
+        expect(@source.selected.is_empty()).to.be.false
+
+    describe "mode='inspect'", ->
+
+      beforeEach ->
+        @id = @gr.id
+        # Have to manually add this. It's normally added selection_manager.inspect
+        @source.selection_manager.inspectors[@id] = new Selector()
+
+      it "should return false and clear inspections if hit_test result is empty", ->
+        initial_inspection = create_1d_hit_test_result([1,2])
+        @source.inspected = initial_inspection
+        @source.selection_manager.inspectors[@id].indices = initial_inspection
+
+        glyph_view = new HitTestMiss()
+        expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "inspect")).to.be.false
+        indices = @source.selection_manager.inspectors[@id].indices
+        expect(indices.is_empty()).to.be.true
+        expect(@source.inspected.is_empty()).to.be.true
+
+      it "should return true if hit_test result is not empty", ->
+        glyph_view = new HitTestHit()
+        expect(@gr.hit_test_helper("geometry", glyph_view, true, false, "inspect")).to.be.true
+        indices = @source.selection_manager.inspectors[@id].indices
+        expect(indices.is_empty()).to.be.false
+        expect(@source.inspected.is_empty()).to.be.false