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/plotting/figure.py b/bokeh/plotting/figure.py
--- a/bokeh/plotting/figure.py
+++ b/bokeh/plotting/figure.py
@@ -7,7 +7,7 @@
 
 from ..core.properties import Any, Auto, Either, Enum, Int, Seq, Instance, String
 from ..core.enums import HorizontalLocation, VerticalLocation
-from ..models import Plot, Title, Tool
+from ..models import Plot, Title, Tool, GraphRenderer
 from ..models import glyphs, markers
 from ..models.tools import Drag, Inspection, Scroll, Tap
 from ..util.options import Options
@@ -15,7 +15,7 @@
 from ..util._plot_arg_helpers import _convert_responsive
 from .helpers import (
     _get_range, _get_scale, _process_axis_and_grid, _process_tools_arg,
-    _glyph_function, _process_active_tools, _stack)
+    _glyph_function, _process_active_tools, _stack, _graph)
 
 DEFAULT_TOOLS = "pan,wheel_zoom,box_zoom,save,reset,help"
 
@@ -726,6 +726,26 @@ def vbar_stack(self, stackers, **kw):
         for kw in _stack(stackers, "bottom", "top", **kw):
             self.vbar(**kw)
 
+    def graph(self, node_source, edge_source, layout_provider, **kwargs):
+        ''' Creates a network graph using the given node, edge and layout provider.
+
+        Args:
+            node_source (:class:`~bokeh.models.sources.ColumnDataSource`) : a user-supplied data source
+                for the graph nodes. An attempt will be made to convert the object to
+                :class:`~bokeh.models.sources.ColumnDataSource` if needed. If none is supplied, one is created
+                for the user automatically.
+            edge_source (:class:`~bokeh.models.sources.ColumnDataSource`) : a user-supplied data source
+                for the graph edges. An attempt will be made to convert the object to
+                :class:`~bokeh.models.sources.ColumnDataSource` if needed. If none is supplied, one is created
+                for the user automatically.
+            layout_provider (:class:`~bokeh.models.graphs.LayoutProvider`) : a LayoutProvider instance to
+                provide the graph coordinates in Cartesian space.
+            **kwargs: :ref:`userguide_styling_line_properties` and :ref:`userguide_styling_fill_properties`
+        '''
+        kw = _graph(node_source, edge_source, **kwargs)
+        graph_renderer = GraphRenderer(layout_provider=layout_provider, **kw)
+        self.renderers.append(graph_renderer)
+        return graph_renderer
 
 def figure(**kwargs):
     ''' Create a new :class:`~bokeh.plotting.figure.Figure` for plotting.
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -17,8 +17,9 @@
     FactorRange, Grid, HelpTool, HoverTool, LassoSelectTool, Legend, LegendItem, LinearAxis,
     LogAxis, PanTool, ZoomInTool, ZoomOutTool, PolySelectTool, ContinuousTicker,
     SaveTool, Range, Range1d, UndoTool, RedoTool, ResetTool, ResizeTool, Tool,
-    WheelPanTool, WheelZoomTool, ColumnarDataSource, ColumnDataSource, GlyphRenderer,
-    LogScale, LinearScale, CategoricalScale)
+    WheelPanTool, WheelZoomTool, ColumnarDataSource, ColumnDataSource,
+    LogScale, LinearScale, CategoricalScale, Circle, MultiLine)
+from ..models.renderers import GlyphRenderer
 
 from ..core.properties import ColorSpec, Datetime, value, field
 from ..transform import stack
@@ -28,8 +29,6 @@
 
 pd = import_optional('pandas')
 
-DEFAULT_PALETTE = ["#f22c40", "#5ab738", "#407ee7", "#df5320", "#00ad9c", "#c33ff3"]
-
 
 def _stack(stackers, spec0, spec1, **kw):
     for name in (spec0, spec1):
@@ -68,6 +67,110 @@ def _stack(stackers, spec0, spec1, **kw):
 
     return _kw
 
+def _graph(node_source, edge_source, **kwargs):
+
+    if not isinstance(node_source, ColumnarDataSource):
+        try:
+            # try converting the soruce to ColumnDataSource
+            node_source = ColumnDataSource(node_source)
+        except ValueError as err:
+            msg = "Failed to auto-convert {curr_type} to ColumnDataSource.\n Original error: {err}".format(
+                curr_type=str(type(node_source)),
+                err=err.message
+            )
+            reraise(ValueError, ValueError(msg), sys.exc_info()[2])
+
+    if not isinstance(edge_source, ColumnarDataSource):
+        try:
+            # try converting the soruce to ColumnDataSource
+            edge_source = ColumnDataSource(edge_source)
+        except ValueError as err:
+            msg = "Failed to auto-convert {curr_type} to ColumnDataSource.\n Original error: {err}".format(
+                curr_type=str(type(edge_source)),
+                err=err.message
+            )
+            reraise(ValueError, ValueError(msg), sys.exc_info()[2])
+
+    ## node stuff
+    if any(x.startswith('node_selection_') for x in kwargs):
+        snode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_selection_")
+    else:
+        snode_ca = None
+
+    if any(x.startswith('node_hover_') for x in kwargs):
+        hnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_hover_")
+    else:
+        hnode_ca = None
+
+    if any(x.startswith('node_muted_') for x in kwargs):
+        mnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_muted_")
+    else:
+        mnode_ca = None
+
+    nsnode_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_nonselection_")
+    node_ca = _pop_colors_and_alpha(Circle, kwargs, prefix="node_")
+
+    ## edge stuff
+    if any(x.startswith('edge_selection_') for x in kwargs):
+        sedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_selection_")
+    else:
+        sedge_ca = None
+
+    if any(x.startswith('edge_hover_') for x in kwargs):
+        hedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_hover_")
+    else:
+        hedge_ca = None
+
+    if any(x.startswith('edge_muted_') for x in kwargs):
+        medge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_muted_")
+    else:
+        medge_ca = None
+
+    nsedge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_nonselection_")
+    edge_ca = _pop_colors_and_alpha(MultiLine, kwargs, prefix="edge_")
+
+    ## node stuff
+    node_kwargs = {k.lstrip('node_'): v for k, v in kwargs.copy().items() if k.lstrip('node_') in Circle.properties()}
+
+    node_glyph = _make_glyph(Circle, node_kwargs, node_ca)
+    nsnode_glyph = _make_glyph(Circle, node_kwargs, nsnode_ca)
+    snode_glyph = _make_glyph(Circle, node_kwargs, snode_ca)
+    hnode_glyph = _make_glyph(Circle, node_kwargs, hnode_ca)
+    mnode_glyph = _make_glyph(Circle, node_kwargs, mnode_ca)
+
+    node_renderer = GlyphRenderer(glyph=node_glyph,
+                                  nonselection_glyph=nsnode_glyph,
+                                  selection_glyph=snode_glyph,
+                                  hover_glyph=hnode_glyph,
+                                  muted_glyph=mnode_glyph,
+                                  data_source=node_source)
+
+    ## edge stuff
+    edge_kwargs = {k.lstrip('edge_'): v for k, v in kwargs.copy().items() if k.lstrip('edge_') in MultiLine.properties()}
+
+    edge_glyph = _make_glyph(MultiLine, edge_kwargs, edge_ca)
+    nsedge_glyph = _make_glyph(MultiLine, edge_kwargs, nsedge_ca)
+    sedge_glyph = _make_glyph(MultiLine, edge_kwargs, sedge_ca)
+    hedge_glyph = _make_glyph(MultiLine, edge_kwargs, hedge_ca)
+    medge_glyph = _make_glyph(MultiLine, edge_kwargs, medge_ca)
+
+    edge_renderer = GlyphRenderer(glyph=edge_glyph,
+                                  nonselection_glyph=nsedge_glyph,
+                                  selection_glyph=sedge_glyph,
+                                  hover_glyph=hedge_glyph,
+                                  muted_glyph=medge_glyph,
+                                  data_source=edge_source)
+
+    _RENDERER_ARGS = ['name', 'level', 'visible', 'x_range_name', 'y_range_name',
+                      'selection_policy', 'inspection_policy']
+
+    renderer_kwargs = {attr: kwargs.pop(attr) for attr in _RENDERER_ARGS if attr in kwargs}
+
+    renderer_kwargs["node_renderer"] = node_renderer
+    renderer_kwargs["edge_renderer"] = edge_renderer
+
+    return renderer_kwargs
+
 def get_default_color(plot=None):
     colors = [
         "#1f77b4",
@@ -90,10 +193,6 @@ def get_default_color(plot=None):
         return colors[0]
 
 
-def get_default_alpha(plot=None):
-    return 1.0
-
-
 _RENDERER_ARGS = ['name', 'x_range_name', 'y_range_name',
                   'level', 'view', 'visible', 'muted']
 
diff --git a/examples/plotting/file/graphs.py b/examples/plotting/file/graphs.py
--- a/examples/plotting/file/graphs.py
+++ b/examples/plotting/file/graphs.py
@@ -1,56 +1,51 @@
-import networkx as nx
-from sympy import Symbol, symbols, Dummy, roots, solve
-
-from bokeh.plotting import figure, gridplot, GridSpec, output_file, show
-
-def graph_draw(g, layout=nx.circular_layout, node_color="white", text_color="black"):
-    pos = layout(g)
-    labels = [ str(v) for v in g.nodes() ]
-    vx, vy = zip(*[ pos[v] for v in g.nodes() ])
-    xs, ys = [], []
-    for (a, b) in g.edges():
-        x0, y0 = pos[a]
-        x1, y1 = pos[b]
-        xs.append([x0, x1])
-        ys.append([y0, y1])
-    f = figure(plot_width=300, plot_height=300,
-               x_axis_type=None, y_axis_type=None,
-               outline_line_color=None,
-               tools=[], toolbar_location=None)
-    f.multi_line(xs, ys, line_color="black")
-    f.circle(vx, vy, size=16, line_color="black", fill_color=node_color)
-    f.text(vx, vy, text=labels, text_color=text_color,
-           text_font_size="10px", text_align="center", text_baseline="middle")
-    return f
-
-V = range(1, 12+1)
-E = [(1,2),(2,3),(1,4),(1,6),(1,12),(2,5),(2,7),(3,8),(3,10),(4,11),(4,9),(5,6),
-     (6,7),(7,8),(8,9),(9,10),(10,11),(11,12),(5,12),(5,9),(6,10),(7,11),(8,12)]
-
-g = nx.Graph()
-g.add_nodes_from(V)
-g.add_edges_from(E)
-
-Vx = [ Symbol('x%d' % i) for i in V ]
-Ex = [ (Vx[i-1], Vx[j-1]) for i, j in E ]
-F3 = [ xi**3 - 1 for xi in Vx ]
-Fg = [ xi**2 + xi*xj + xj**2 for xi, xj in Ex ]
-Fx = F3 + Fg
-
-colors = symbols('red,green,blue')
-roots_of_unity = roots(Dummy()**3 - 1, multiple=True)
-color_map = dict(zip(roots_of_unity, colors))
-solutions = solve(Fx, *Vx)
-colorings = [ [ color_map.get(zeta) for zeta in solution ] for solution in solutions ]
-
-n, ncols = len(colorings), 2
-gs = GridSpec((n + 1)//ncols, 1 + ncols)
-gs[0, 0] = graph_draw(g)
-
-for i, coloring in enumerate(colorings):
-    f = graph_draw(g, node_color=[ str(color) for color in coloring ], text_color="white")
-    gs[i//ncols, 1 + i%ncols] = f
-plot = gridplot(gs, toolbar_location=None)
-
-output_file("graphs.html", title="Graph k-coloring with computer algebra")
-show(plot)
+from bokeh.models import StaticLayoutProvider, ColumnDataSource, HoverTool, TapTool
+from bokeh.models.graphs import NodesAndLinkedEdges
+from bokeh.palettes import Set3_12
+from bokeh.plotting import figure, show, output_file
+from bokeh.sampledata.us_states import data as us_states
+from bokeh.sampledata.airport_routes import airports, routes
+
+import numpy as np
+
+output_file("graphs.html")
+
+airports.set_index("AirportID", inplace=True)
+airports.index.rename("index", inplace=True)
+routes.rename(columns={"SourceID": "start", "DestinationID": "end"}, inplace=True)
+
+lats, lons = [], []
+for k, v in us_states.items():
+    lats.append(np.array(v['lats']))
+    lons.append(np.array(v['lons']))
+
+source = ColumnDataSource(data=dict(lats=lats, lons=lons))
+
+graph_layout = dict(zip(airports.index.astype(str), zip(airports.Longitude, airports.Latitude)))
+layout_provider = StaticLayoutProvider(graph_layout=graph_layout)
+
+fig = figure(x_range=(-180, -60), y_range=(15,75),
+              x_axis_label="Longitude", y_axis_label="Latitude",
+              plot_width=800, plot_height=600, background_fill_color=Set3_12[4],
+              background_fill_alpha=0.2, tools='box_zoom,reset')
+
+fig.patches(xs="lons", ys="lats", line_color='grey', line_width=1.0,
+             fill_color=Set3_12[10], source=source)
+
+r = fig.graph(airports, routes, layout_provider,
+              ## node style props
+              node_fill_color=Set3_12[3], node_fill_alpha=0.4, node_line_color="black", node_line_alpha=0.3,
+              node_nonselection_fill_color=Set3_12[3], node_nonselection_fill_alpha=0.2, node_nonselection_line_alpha=0.1,
+              node_selection_fill_color=Set3_12[3], node_selection_fill_alpha=0.8, node_selection_line_alpha=0.3,
+              ## edge style props
+              edge_line_color="black", edge_line_alpha=0.04,
+              edge_hover_line_alpha=0.6, edge_hover_line_color=Set3_12[1],
+              edge_nonselection_line_color="black", edge_nonselection_line_alpha=0.01,
+              edge_selection_line_alpha=0.6, edge_selection_line_color=Set3_12[1],
+              ## graph policies
+              inspection_policy=NodesAndLinkedEdges(), selection_policy=NodesAndLinkedEdges())
+
+hover = HoverTool(tooltips=[("Airport", "@Name (@IATA), @City ")], renderers=[r])
+tap = TapTool(renderers=[r])
+fig.add_tools(hover, tap)
+
+show(fig)


Test patch:
diff --git a/bokeh/plotting/tests/test_helpers.py b/bokeh/plotting/tests/test_helpers.py
--- a/bokeh/plotting/tests/test_helpers.py
+++ b/bokeh/plotting/tests/test_helpers.py
@@ -1,5 +1,6 @@
 from mock import mock
 
+import pandas as pd
 import pytest
 
 from bokeh.models import ColumnDataSource, CDSView, Marker
@@ -7,7 +8,7 @@
 from bokeh.models.scales import LinearScale, LogScale, CategoricalScale
 from bokeh.plotting import Figure
 from bokeh.plotting.helpers import (_get_legend_item_label, _get_scale,
-                                    _get_range, _stack, _glyph_function,
+                                    _get_range, _stack, _graph, _glyph_function,
                                     _RENDERER_ARGS)
 
 
@@ -65,6 +66,65 @@ def test__stack_broadcast_with_list_kwargs():
         assert kw['foo'] == [10, 20, 30, 40][i]
         assert kw['bar'] == "baz"
 
+def test__graph_will_convert_dataframes_to_sources():
+    node_source = pd.DataFrame(data=dict(foo=[]))
+    edge_source = pd.DataFrame(data=dict(start=[], end=[], bar=[]))
+
+    kw = _graph(node_source, edge_source)
+
+    # 'index' column is added from pandas df
+    assert set(kw['node_renderer'].data_source.data.keys()) == {"index", "foo"}
+    assert set(kw['edge_renderer'].data_source.data.keys()) == {"index", "start", "end", "bar"}
+
+def test__graph_will_handle_sources_correctly():
+    node_source = ColumnDataSource(data=dict(foo=[]))
+    edge_source = ColumnDataSource(data=dict(start=[], end=[], bar=[]))
+
+    kw = _graph(node_source, edge_source)
+
+    assert set(kw['node_renderer'].data_source.data.keys()) == {"foo"}
+    assert set(kw['edge_renderer'].data_source.data.keys()) == {"start", "end", "bar"}
+
+def test__graph_properly_handle_node_property_mixins():
+    kwargs = dict(node_fill_color="purple", node_selection_fill_color="blue",
+                  node_nonselection_fill_color="yellow", node_hover_fill_color="red",
+                  node_muted_fill_color="orange", node_radius=0.6)
+
+    kw = _graph({}, {}, **kwargs)
+
+    r = kw['node_renderer']
+    assert r.glyph.fill_color == "purple"
+    assert r.selection_glyph.fill_color == "blue"
+    assert r.nonselection_glyph.fill_color == "yellow"
+    assert r.hover_glyph.fill_color == "red"
+    assert r.muted_glyph.fill_color == "orange"
+
+    assert r.glyph.radius == 0.6
+    assert r.selection_glyph.radius == 0.6
+    assert r.nonselection_glyph.radius == 0.6
+    assert r.hover_glyph.radius == 0.6
+    assert r.muted_glyph.radius == 0.6
+
+def test__graph_properly_handle_edge_property_mixins():
+    kwargs = dict(edge_line_color="purple", edge_selection_line_color="blue",
+                  edge_nonselection_line_color="yellow", edge_hover_line_color="red",
+                  edge_muted_line_color="orange", edge_line_width=23)
+
+    kw = _graph({}, {}, **kwargs)
+
+    r = kw['edge_renderer']
+    assert r.glyph.line_color == "purple"
+    assert r.selection_glyph.line_color == "blue"
+    assert r.nonselection_glyph.line_color == "yellow"
+    assert r.hover_glyph.line_color == "red"
+    assert r.muted_glyph.line_color == "orange"
+
+    assert r.glyph.line_width == 23
+    assert r.selection_glyph.line_width == 23
+    assert r.nonselection_glyph.line_width == 23
+    assert r.hover_glyph.line_width == 23
+    assert r.muted_glyph.line_width == 23
+
 # _get_legend_item_label
 def test_if_legend_is_something_exotic_that_it_is_passed_directly_to_label():
     kwargs = {