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
@@ -72,3 +72,33 @@ def from_networkx(graph, layout_function, **kwargs):
         graph_renderer.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)
 
         return graph_renderer
+
+
+@abstract
+class GraphHitTestPolicy(Model):
+    '''
+
+    '''
+
+    pass
+
+
+class NodesOnly(GraphHitTestPolicy):
+    '''
+    With the NodesOnly policy, only graph nodes are able to be selected and
+    inspected. There is no selection or inspection of graph edges.
+
+    '''
+
+    pass
+
+class NodesAndLinkedEdges(GraphHitTestPolicy):
+    '''
+    With the NodesAndLinkedEdges policy, inspection or selection of graph
+    nodes will result in the inspection or selection of the node and of the
+    linked graph edges. There is no direct selection or inspection of graph
+    edges.
+
+    '''
+
+    pass
diff --git a/bokeh/models/renderers.py b/bokeh/models/renderers.py
--- a/bokeh/models/renderers.py
+++ b/bokeh/models/renderers.py
@@ -17,7 +17,7 @@
 from ..util.deprecation import deprecated
 
 from .glyphs import Glyph, Circle, MultiLine
-from .graphs import LayoutProvider
+from .graphs import LayoutProvider, GraphHitTestPolicy, NodesOnly
 from .images import ImageSource
 from .sources import ColumnDataSource, DataSource, RemoteSource, CDSView
 from .tiles import TileSource, WMTSTileSource
@@ -242,6 +242,16 @@ def _check_malformed_graph_source(self):
     rendered as the graph edges.
     """)
 
+    selection_policy = Instance(GraphHitTestPolicy, default=lambda: NodesOnly(), help="""
+    An instance of a GraphHitTestPolicy that provides the logic for selection
+    of graph components.
+    """)
+
+    inspection_policy = Instance(GraphHitTestPolicy, default=lambda: NodesOnly(), help="""
+    An instance of a GraphHitTestPolicy that provides the logic for inspection
+    of graph components.
+    """)
+
     level = Override(default="glyph")
 
 @abstract


Test patch:
diff --git a/bokehjs/src/coffee/models/graphs/graph_hit_test_policy.coffee b/bokehjs/src/coffee/models/graphs/graph_hit_test_policy.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/src/coffee/models/graphs/graph_hit_test_policy.coffee
@@ -0,0 +1,93 @@
+import {Model} from "../../model"
+import {contains} from "core/util/array"
+import {create_hit_test_result} from "core/hittest"
+
+
+export class GraphHitTestPolicy extends Model
+
+  do_selection: (geometry, graph_view, final, append) ->
+    return false
+
+  do_inspection: (geometry, graph_view, final, append) ->
+    return false
+
+
+export class NodesOnly extends GraphHitTestPolicy
+  type: 'NodesOnly'
+
+  _do: (geometry, graph_view, final, append) ->
+    node_view = graph_view.node_view
+    hit_test_result = node_view.glyph.hit_test(geometry)
+
+    # glyphs that don't have hit-testing implemented will return null
+    if hit_test_result == null
+      return false
+
+    @_node_selector.update(hit_test_result, final, append)
+
+    return not @_node_selector.indices.is_empty()
+
+  do_selection: (geometry, graph_view, final, append) ->
+    @_node_selector = graph_view.node_view.model.data_source.selection_manager.selector
+    did_hit = @_do(geometry, graph_view, final, append)
+    graph_view.node_view.model.data_source.selected = @_node_selector.indices
+    return did_hit
+
+  do_inspection: (geometry, graph_view, final, append) ->
+    @_node_selector = graph_view.node_view.model.data_source.selection_manager.inspectors[graph_view.model.id]
+    did_hit = @_do(geometry, graph_view, final, append)
+    graph_view.node_view.model.data_source.inspected = @_node_selector.indices
+    return did_hit
+
+export class NodesAndLinkedEdges extends GraphHitTestPolicy
+  type: 'NodesAndLinkedEdges'
+
+  _do: (geometry, graph_view, final, append) ->
+    [node_view, edge_view] = [graph_view.node_view, graph_view.edge_view]
+    hit_test_result = node_view.glyph.hit_test(geometry)
+
+    # glyphs that don't have hit-testing implemented will return null
+    if hit_test_result == null
+      return false
+
+    @_node_selector.update(hit_test_result, final, append)
+
+    node_indices = (node_view.model.data_source.data.index[i] for i in hit_test_result["1d"].indices)
+    edge_source = edge_view.model.data_source
+    edge_indices = []
+    for i in [0...edge_source.data.start.length]
+      if contains(node_indices, edge_source.data.start[i]) or contains(node_indices, edge_source.data.end[i])
+        edge_indices.push(i)
+
+    linked_index = create_hit_test_result()
+    for i in edge_indices
+      linked_index["2d"].indices[i] = [0] #currently only supports 2-element multilines, so this is all of it
+
+    @_edge_selector = edge_view.model.data_source.selection_manager.selector
+    @_edge_selector.update(linked_index, final, append)
+
+    return not @_node_selector.indices.is_empty()
+
+  do_selection: (geometry, graph_view, final, append) ->
+    @_node_selector = graph_view.node_view.model.data_source.selection_manager.selector
+    @_edge_selector = graph_view.edge_view.model.data_source.selection_manager.selector
+
+    did_hit = @_do(geometry, graph_view, final, append)
+
+    graph_view.node_view.model.data_source.selected = @_node_selector.indices
+    graph_view.edge_view.model.data_source.selected = @_edge_selector.indices
+    graph_view.edge_view.model.data_source.select.emit()
+
+    return did_hit
+
+  do_inspection: (geometry, graph_view, final, append) ->
+    @_node_selector = graph_view.node_view.model.data_source.selection_manager.inspectors[graph_view.model.id]
+    @_edge_selector = graph_view.edge_view.model.data_source.selection_manager.inspectors[graph_view.edge_view.model.id]
+
+    did_hit = @_do(geometry, graph_view, final, append)
+
+    graph_view.node_view.model.data_source.inspected = @_node_selector.indices
+    graph_view.edge_view.model.data_source.inspected = @_edge_selector.indices
+    graph_view.edge_view.model.data_source.inspect.emit()
+
+    return did_hit
diff --git a/bokehjs/test/models/graphs/graph_hit_test_policy.coffee b/bokehjs/test/models/graphs/graph_hit_test_policy.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/graphs/graph_hit_test_policy.coffee
@@ -0,0 +1,186 @@
+{expect} = require "chai"
+sinon = require "sinon"
+utils = require "../../utils"
+
+{build_views} = utils.require("core/build_views")
+{create_1d_hit_test_result, create_hit_test_result} = utils.require("core/hittest")
+{Selector} = utils.require("core/selector")
+
+{Plot} = utils.require("models/plots/plot")
+{Range1d} = utils.require("models/ranges/range1d")
+
+{Circle} = utils.require("models/glyphs/circle")
+{MultiLine} = utils.require("models/glyphs/multi_line")
+{NodesOnly, NodesAndLinkedEdges} = utils.require("models/graphs/graph_hit_test_policy")
+{LayoutProvider} = utils.require("models/graphs/layout_provider")
+{GlyphRenderer} = utils.require("models/renderers/glyph_renderer")
+{GraphRenderer} = utils.require("models/renderers/graph_renderer")
+{ColumnDataSource} = utils.require("models/sources/column_data_source")
+
+
+describe "GraphHitTestPolicy", ->
+
+  afterEach ->
+    utils.unstub_canvas()
+    @gv.node_view.glyph.hit_test.restore()
+
+  beforeEach ->
+    utils.stub_canvas()
+
+    @plot = new Plot({
+       x_range: new Range1d({start: 0, end: 1})
+       y_range: new Range1d({start: 0, end: 1})
+    })
+    @plot_view = new @plot.default_view({model: @plot, parent: null})
+    @plot_canvas_view = new @plot.plot_canvas.default_view({model: @plot.plot_canvas, parent: @plot_view})
+
+    @node_source = new ColumnDataSource({
+      data: {
+        index: [10, 20, 30, 40],
+      }
+    })
+    @edge_source = new ColumnDataSource({
+      data: {
+        start: [10, 10, 30],
+        end: [20, 30, 20],
+      }
+    })
+    @node_renderer = new GlyphRenderer({data_source: @node_source, glyph: new Circle()})
+    @edge_renderer = new GlyphRenderer({data_source: @edge_source, glyph: new MultiLine()})
+
+    @gr = new GraphRenderer({
+      node_renderer: @node_renderer
+      edge_renderer: @edge_renderer
+      layout_provider: new LayoutProvider()
+    })
+
+    @gv = new @gr.default_view({
+      model: @gr
+      plot_view: @plot_canvas_view
+      parent: @plot_canvas_view
+    })
+
+    @stub = sinon.stub(@gv.node_view.glyph, "hit_test")
+
+  describe "NodesOnly", ->
+
+    describe "do_selection method", ->
+
+      it "should return false if GlyphView doesn't have hit-testing and returns null", ->
+        @stub.returns(null)
+        policy = new NodesOnly()
+        expect(policy.do_selection("geometry", @gv, true, false)).to.be.false
+
+      it "should return false and clear selections if hit_test result is empty", ->
+        initial_selection = create_1d_hit_test_result([[1], [2]])
+        @node_source.selected = initial_selection
+        @node_source.selection_manager.selector.indices = initial_selection
+
+        @stub.returns(create_hit_test_result())
+        policy = new NodesOnly()
+
+        expect(policy.do_selection("geometry", @gv, true, false)).to.be.false
+        indices = @node_source.selection_manager.selector.indices
+        expect(indices.is_empty()).to.be.true
+        expect(@node_source.selected.is_empty()).to.be.true
+
+      it "should return true if hit_test result is not empty", ->
+        @stub.returns(create_1d_hit_test_result([[0], [1]]))
+        policy = new NodesOnly()
+
+        expect(policy.do_selection("geometry", @gv, true, false)).to.be.true
+        indices = @node_source.selection_manager.selector.indices
+        expect(indices.is_empty()).to.be.false
+        expect(@node_source.selected.is_empty()).to.be.false
+
+    describe "do_inspection method", ->
+
+      beforeEach ->
+        @id = @gr.id
+        # Have to manually add this. It's normally added selection_manager.inspect
+        @node_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]])
+        @node_source.inspected = initial_inspection
+        @node_source.selection_manager.inspectors[@id].indices = initial_inspection
+
+        @stub.returns(create_hit_test_result())
+        policy = new NodesOnly()
+
+        expect(policy.do_inspection("geometry", @gv, true, false)).to.be.false
+        indices = @node_source.selection_manager.inspectors[@id].indices
+        expect(indices.is_empty()).to.be.true
+        expect(@node_source.inspected.is_empty()).to.be.true
+
+      it "should return true if hit_test result is not empty", ->
+        @stub.returns(create_1d_hit_test_result([[0], [1]]))
+        policy = new NodesOnly()
+
+        expect(policy.do_inspection("geometry", @gv, true, false)).to.be.true
+        indices = @node_source.selection_manager.inspectors[@id].indices
+        expect(indices.is_empty()).to.be.false
+        expect(@node_source.inspected.is_empty()).to.be.false
+
+  describe "NodesAndLinkedEdges", ->
+
+    describe "do_selection method", ->
+
+      it "should clear edge selections if hit_test result is empty", ->
+        initial_selection = create_hit_test_result()
+        initial_selection["2d"].indices = {0: [0, 1], 1: [0]}
+        @edge_source.selected = initial_selection
+        @edge_source.selection_manager.selector.indices = initial_selection
+
+        @stub.returns(create_hit_test_result())
+        policy = new NodesAndLinkedEdges()
+
+        policy.do_selection("geometry", @gv, true, false)
+
+        indices = @edge_source.selection_manager.selector.indices
+        expect(indices.is_empty()).to.be.true
+        expect(@edge_source.selected.is_empty()).to.be.true
+
+      it "should select linked edges if hit_Test result is not empty", ->
+        @stub.returns(create_1d_hit_test_result([[0], ]))
+        policy = new NodesAndLinkedEdges()
+
+        policy.do_selection("geometry", @gv, true, false)
+
+        indices = @edge_source.selection_manager.selector.indices
+        expect(indices['2d'].indices).to.be.deep.equal({ '0': [ 0 ], '1': [ 0 ] })
+        expect(@edge_source.selected['2d'].indices).to.be.deep.equal({ '0': [ 0 ], '1': [ 0 ] })
+
+    describe "do_inspection method", ->
+
+      beforeEach ->
+        @id = @gr.id
+        # Have to manually add this. It's normally added selection_manager.inspect
+        @node_source.selection_manager.inspectors[@id] = new Selector()
+        @edge_source.selection_manager.inspectors[@edge_renderer.id] = new Selector()
+
+      it "should clear edge inspections if hit_test result is empty", ->
+        initial_inspection = create_hit_test_result()
+        initial_inspection["2d"].indices = {0: [0, 1], 1: [0]}
+        @edge_source.inspected = initial_inspection
+        @edge_source.selection_manager.inspectors[@edge_renderer.id].indices = initial_inspection
+
+        @stub.returns(create_hit_test_result())
+        policy = new NodesAndLinkedEdges()
+
+        policy.do_inspection("geometry", @gv, true, false)
+
+        indices = @edge_source.selection_manager.inspectors[@edge_renderer.id].indices
+
+        # expect(indices.is_empty()).to.be.true
+        expect(@edge_source.inspected.is_empty()).to.be.true
+
+      it "should select linked edges if hit_Test result is not empty", ->
+        @stub.returns(create_1d_hit_test_result([[0], ]))
+        policy = new NodesAndLinkedEdges()
+
+        policy.do_inspection("geometry", @gv, true, false)
+
+        indices = @edge_source.selection_manager.inspectors[@edge_renderer.id].indices
+        # expect(indices['2d'].indices).to.be.deep.equal({ '0': [ 0 ], '1': [ 0 ] })
+        expect(@edge_source.inspected['2d'].indices).to.be.deep.equal({ '0': [ 0 ], '1': [ 0 ] })
diff --git a/bokehjs/test/models/graphs/index.coffee b/bokehjs/test/models/graphs/index.coffee
--- a/bokehjs/test/models/graphs/index.coffee
+++ b/bokehjs/test/models/graphs/index.coffee
@@ -1 +1,2 @@
+require "./graph_hit_test_policy"
 require "./static_layout_provider"
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
@@ -55,7 +55,7 @@ describe "GlyphRenderer", ->
       hit_test: (geometry) -> return create_1d_hit_test_result([])
 
     class HitTestHit
-      hit_test: (geometry) -> return create_1d_hit_test_result([0, 1])
+      hit_test: (geometry) -> return create_1d_hit_test_result([[0], [1]])
 
     it "should return false if @visible is false", ->
       @gr.visible = false