Repository: bokeh/bokeh

Issue:
Allow setting Graph node_marker [FEATURE]
Currently the graph plotting helper function uses the Circle glyph for all nodes. It would be nice if it used the Scatter glyph instead to allow easily changing the marker.


Discussion:

Resolving patch:
diff --git a/bokeh/models/markers.py b/bokeh/models/markers.py
--- a/bokeh/models/markers.py
+++ b/bokeh/models/markers.py
@@ -288,6 +288,25 @@ class X(Marker):
 # Dev API
 #-----------------------------------------------------------------------------
 
+marker_types = {
+    "asterisk": Asterisk,
+    "circle": Circle,
+    "circle_cross": CircleCross,
+    "circle_x": CircleX,
+    "cross": Cross,
+    "dash": Dash,
+    "diamond": Diamond,
+    "diamond_cross": DiamondCross,
+    "hex": Hex,
+    "inverted_triangle": InvertedTriangle,
+    "scatter": Scatter,
+    "square": Square,
+    "square_cross": SquareCross,
+    "square_x": SquareX,
+    "triangle": Triangle,
+    "x": X
+}
+
 #-----------------------------------------------------------------------------
 # Private API
 #-----------------------------------------------------------------------------
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -36,7 +36,6 @@
     BoxZoomTool,
     CategoricalAxis,
     CategoricalScale,
-    Circle,
     ColumnarDataSource,
     ColumnDataSource,
     ContinuousTicker,
@@ -68,6 +67,7 @@
     RedoTool,
     ResetTool,
     SaveTool,
+    Scatter,
     TapTool,
     Tool,
     UndoTool,
@@ -76,6 +76,7 @@
     ZoomInTool,
     ZoomOutTool,
 )
+from ..models.markers import marker_types
 from ..transform import stack
 from ..util.dependencies import import_optional
 from ..util.deprecation import deprecated
@@ -198,7 +199,7 @@ def _graph(node_source, edge_source, **kwargs):
 
     if not isinstance(node_source, ColumnarDataSource):
         try:
-            # try converting the soruce to ColumnDataSource
+            # try converting the source 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(
@@ -209,7 +210,7 @@ def _graph(node_source, edge_source, **kwargs):
 
     if not isinstance(edge_source, ColumnarDataSource):
         try:
-            # try converting the soruce to ColumnDataSource
+            # try converting the source 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(
@@ -218,25 +219,39 @@ def _graph(node_source, edge_source, **kwargs):
             )
             raise ValueError(msg).with_traceback(sys.exc_info()[2])
 
+    marker = kwargs.pop('node_marker', None)
+    if isinstance(marker, dict) and 'field' in marker or marker in node_source.data:
+        marker_type = Scatter
+        kwargs['node_marker'] = field(marker)
+    else:
+        if isinstance(marker, dict) and 'value' in marker:
+            marker = marker['value']
+        elif marker is None:
+            marker = 'circle'
+        marker_type = marker_types.get(marker)
+    if marker_type is None:
+        msg = "Could not determine marker type for node_marker={marker}".format(marker=repr(marker))
+        raise ValueError(msg)
+
     ## node stuff
-    node_ca = _pop_visuals(Circle, kwargs, prefix="node_")
+    node_ca = _pop_visuals(marker_type, kwargs, prefix="node_")
 
     if any(x.startswith('node_selection_') for x in kwargs):
-        snode_ca = _pop_visuals(Circle, kwargs, prefix="node_selection_", defaults=node_ca)
+        snode_ca = _pop_visuals(marker_type, kwargs, prefix="node_selection_", defaults=node_ca)
     else:
         snode_ca = None
 
     if any(x.startswith('node_hover_') for x in kwargs):
-        hnode_ca = _pop_visuals(Circle, kwargs, prefix="node_hover_", defaults=node_ca)
+        hnode_ca = _pop_visuals(marker_type, kwargs, prefix="node_hover_", defaults=node_ca)
     else:
         hnode_ca = None
 
     if any(x.startswith('node_muted_') for x in kwargs):
-        mnode_ca = _pop_visuals(Circle, kwargs, prefix="node_muted_", defaults=node_ca)
+        mnode_ca = _pop_visuals(marker_type, kwargs, prefix="node_muted_", defaults=node_ca)
     else:
         mnode_ca = None
 
-    nsnode_ca = _pop_visuals(Circle, kwargs, prefix="node_nonselection_", defaults=node_ca)
+    nsnode_ca = _pop_visuals(marker_type, kwargs, prefix="node_nonselection_", defaults=node_ca)
 
     ## edge stuff
     edge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_")
@@ -259,13 +274,13 @@ def _graph(node_source, edge_source, **kwargs):
     nsedge_ca = _pop_visuals(MultiLine, kwargs, prefix="edge_nonselection_", defaults=edge_ca)
 
     ## node stuff
-    node_kwargs = {k.lstrip('node_'): v for k, v in kwargs.copy().items() if k.lstrip('node_') in Circle.properties()}
+    node_kwargs = {k.lstrip('node_'): v for k, v in kwargs.copy().items() if k.lstrip('node_') in marker_type.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_glyph = _make_glyph(marker_type, node_kwargs, node_ca)
+    nsnode_glyph = _make_glyph(marker_type, node_kwargs, nsnode_ca)
+    snode_glyph = _make_glyph(marker_type, node_kwargs, snode_ca)
+    hnode_glyph = _make_glyph(marker_type, node_kwargs, hnode_ca)
+    mnode_glyph = _make_glyph(marker_type, node_kwargs, mnode_ca)
 
     node_renderer = GlyphRenderer(glyph=node_glyph,
                                   nonselection_glyph=nsnode_glyph,


Test patch:
diff --git a/tests/unit/bokeh/models/test_markers.py b/tests/unit/bokeh/models/test_markers.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/bokeh/models/test_markers.py
@@ -0,0 +1,44 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2019, Anaconda, Inc., and Bokeh Contributors.
+# All rights reserved.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Bokeh imports
+# Module under test
+from bokeh.models.markers import Marker, marker_types
+
+#-----------------------------------------------------------------------------
+# Setup
+#-----------------------------------------------------------------------------
+
+def test_all_markers_in_markers_dictionary():
+    not_found = [marker for marker in Marker.__subclasses__()
+                 if marker not in marker_types.values()]
+    assert len(not_found) == 0
+
+#-----------------------------------------------------------------------------
+# General API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Dev API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Code
+#-----------------------------------------------------------------------------
diff --git a/tests/unit/bokeh/plotting/test_helpers.py b/tests/unit/bokeh/plotting/test_helpers.py
--- a/tests/unit/bokeh/plotting/test_helpers.py
+++ b/tests/unit/bokeh/plotting/test_helpers.py
@@ -42,6 +42,8 @@
     Marker,
     MercatorAxis,
     Range1d,
+    Scatter,
+    X,
 )
 from bokeh.plotting import Figure
 
@@ -457,6 +459,20 @@ def test__graph_properly_handle_node_property_mixins():
     assert r.hover_glyph.radius == 0.6
     assert r.muted_glyph.radius == 0.6
 
+def test__graph_handles_node_marker_correctly():
+    kw = bph._graph({}, {}, node_marker='x')
+    node_glyph = kw['node_renderer'].glyph
+    assert isinstance(node_glyph, X)
+
+def test__graph_handles_node_marker_dataspec_correctly():
+    node_source = {'marker': ['square', 'circle', 'x']}
+
+    kw = bph._graph(node_source, {}, node_marker='marker')
+
+    node_glyph = kw['node_renderer'].glyph
+    assert isinstance(node_glyph, Scatter)
+    assert node_glyph.marker == {'field': 'marker'}
+
 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",