Repository: nilearn/nilearn

Issue:
Enhancement for plotting.view_connectome: nodes size & color
H!
`plotting.view_connectome` offers 3D plotting of connections (edges with varying weights represented as adjacency matrix) together with spheres (nodes) with a fixed color and specified size (the same for all markers). We propose to provide an enhancement to the `plotting.view_connectome` by 
including varying markers colors/sizes (specified as arrays of nodes' colors and sizes). As `plotting.plot_connectome` already provides such functionality, the enhancement would make the connectome plotting tools in Nilearn more consistent.

Such functionality is also available in `plotting.view_markers` but that plotting option does not enable to visualize edges of the network. 

Do you think it is reasonable and feasible to work on such enhancement (+tutorial demonstration) during the Nilearn Dev Days sprint?

Many researchers in network neuroscience that use [BrainNet Viewer](https://www.nitrc.org/project/list_screenshots.php?group_id=504&screenshot_id=710) for plotting might appreciate such enhancement!   


Discussion:
Cc @jeromedockes : what do you think?
> H!
> `plotting.view_connectome` offers 3D plotting of connections (edges with varying weights represented as adjacency matrix) together with spheres (nodes) with a fixed color and specified size (the same for all markers). We propose to provide an enhancement to the `plotting.view_connectome` by 
> including varying markers colors/sizes (specified as arrays of nodes' colors and sizes).
>
> Such functionality is available in `plotting.view_markers` but that plotting option does not enable not to visualize edges of the network. 
>
> Do you think it is reasonable and feasible to work on such enhancement (+tutorial demonstration) during the Nilearn Dev Days sprint?
>
> Many researchers in network neuroscience that use [BrainNet Viewer](https://www.nitrc.org/project/list_screenshots.php?group_id=504&screenshot_id=710) for plotting might appreciate such enhancement!   

I think that's a good idea!

I think you did a lot of work on this during the sprint right @kfinc ? Can you point me to the related PR? Was it merged ?

Thanks a lot
@kfinc do you want to work on this ?
Best,
I'm under the impression this was adressed in the sprint and finally merged but can"t find the PR? Can I close @kfinc @bthirion ?
Actuall it was not done. I think this kind of feature is a nice project for a future sprint.
I'm looking into this.

Resolving patch:
diff --git a/nilearn/plotting/html_connectome.py b/nilearn/plotting/html_connectome.py
--- a/nilearn/plotting/html_connectome.py
+++ b/nilearn/plotting/html_connectome.py
@@ -1,9 +1,9 @@
 import json
 
 import numpy as np
+from matplotlib import cm as mpl_cm
 from scipy import sparse
 
-from nilearn._utils import rename_parameters
 from .. import datasets
 from . import cm
 
@@ -17,7 +17,57 @@ class ConnectomeView(HTMLDocument):
     pass
 
 
+def _encode_coordinates(coords, prefix):
+    """
+    Transform a 2D-array of 3D data (x, y, z) into a dict of base64 values
+
+    Parameters
+    ----------
+    coords : ndarray, shape=(n_nodes, 3)
+        The coordinates of the nodes in MNI space.
+
+    prefix : str
+        Prefix for the key value in the returned dict. Schema is {prefix}{x|y|z}
+
+    Returns
+    -------
+    coordinates : dict
+        Dictionary containing base64 values for each axis
+    """
+    coordinates = {}
+
+    coords = np.asarray(coords, dtype='<f4')
+    marker_x, marker_y, marker_z = coords.T
+    for coord, cname in [(marker_x, "x"), (marker_y, "y"), (marker_z, "z")]:
+        coordinates["{}{}".format(prefix, cname)] = encode(
+            np.asarray(coord, dtype='<f4'))
+
+    return coordinates
+
+
 def _prepare_line(edges, nodes):
+    """prepare a plotly scatter3d line plot so that a set of disconnected edges
+    can be drawn as a single line.
+
+    `edges` are values associated with each edge (that get mapped to colors
+    through a colorscale). `nodes` are pairs of (source, target) node indices
+    for each edge.
+
+    the color of a line segment in plotly is a mixture of the colors associated
+    with the points it connects. Moreover, segments that begin or end at a
+    point whose value is `null` are not drawn.
+
+    given edges = [eab, ecd, eef] and nodes = [(a, b), (c, d), (e, f)], this
+    function returns:
+
+        path_edges: eab eab   0 ecd ecd   0 eef eef   0
+        path_nodes:   a   b   0   c   d   0   e   f   0
+
+    moreover the javascript code replaces every third element (the '0' in the
+    lists above) with `null`, so only the a-b, c-d, and e-f segments will get
+    plotted, and their colors are correct because both their start and end
+    points are associated with the same value.
+    """
     path_edges = np.zeros(len(edges) * 3, dtype=int)
     path_edges[::3] = edges
     path_edges[1::3] = edges
@@ -27,17 +77,75 @@ def _prepare_line(edges, nodes):
     return path_edges, path_nodes
 
 
-def _get_connectome(adjacency_matrix, coords, threshold=None,
-                    marker_size=None, cmap=cm.cold_hot, symmetric_cmap=True):
-    connectome = {}
-    coords = np.asarray(coords, dtype='<f4')
+def _prepare_colors_for_markers(marker_color, number_of_nodes):
+    """
+    Generate "color" and "colorscale" attributes based on `marker_color` mode
+
+    Parameters
+    ----------
+    marker_color : color or sequence of colors, optional
+        Color(s) of the nodes. Default='auto'.
+
+    number_of_nodes : int
+        Number of nodes in the view
+
+    Returns
+    -------
+    markers_colors: list
+        List of `number_of_nodes` colors as hexadecimal values
+    """
+    if isinstance(marker_color, str) and marker_color == 'auto':
+        colors = mpl_cm.viridis(np.linspace(0, 1, number_of_nodes))
+    elif isinstance(marker_color, str):
+        colors = [marker_color] * number_of_nodes
+    else:
+        colors = marker_color
+
+    return to_color_strings(colors)
+
+
+def _prepare_lines_metadata(adjacency_matrix, coords, threshold,
+                            cmap, symmetric_cmap):
+    """
+    Generate metadata related to lines for _connectome_view plot
+
+    Parameters
+    ----------
+    adjacency_matrix : ndarray, shape=(n_nodes, n_nodes)
+        The weights of the edges.
+
+    coords : ndarray, shape=(n_nodes, 3)
+        The coordinates of the nodes in MNI space.
+
+    threshold : str, number or None, optional
+        If None, no thresholding.
+        If it is a number only connections of amplitude greater
+        than threshold will be shown.
+        If it is a string it must finish with a percent sign,
+        e.g. "25.3%", and only connections of amplitude above the
+        given percentile will be shown.
+
+    cmap : str or matplotlib colormap, optional
+        Colormap to use. Default=cm.bwr.
+
+    symmetric_cmap : bool, optional
+        Make colormap symmetric (ranging from -vmax to vmax).
+        Default=True.
+
+    Returns
+    -------
+    coordinates : dict
+        Dictionary containing base64 values for each axis
+    """
+    lines_metadata = {}
+
     adjacency_matrix = np.nan_to_num(adjacency_matrix, copy=True)
     colors = colorscale(
         cmap, adjacency_matrix.ravel(), threshold=threshold,
         symmetric_cmap=symmetric_cmap)
-    connectome['colorscale'] = colors['colors']
-    connectome['cmin'] = float(colors['vmin'])
-    connectome['cmax'] = float(colors['vmax'])
+    lines_metadata['line_colorscale'] = colors['colors']
+    lines_metadata['line_cmin'] = float(colors['vmin'])
+    lines_metadata['line_cmax'] = float(colors['vmax'])
     if threshold is not None:
         adjacency_matrix[
             np.abs(adjacency_matrix) <= colors['abs_threshold']] = 0
@@ -45,32 +153,60 @@ def _get_connectome(adjacency_matrix, coords, threshold=None,
     nodes = np.asarray([s.row, s.col], dtype=int).T
     edges = np.arange(len(nodes))
     path_edges, path_nodes = _prepare_line(edges, nodes)
-    connectome["_con_w"] = encode(np.asarray(s.data, dtype='<f4')[path_edges])
-    c = coords[path_nodes]
+    lines_metadata["_con_w"] = encode(np.asarray(s.data, dtype='<f4')[path_edges])
+
+    line_coords = coords[path_nodes]
+
+    lines_metadata = {
+        **lines_metadata,
+        **_encode_coordinates(line_coords, prefix="_con_")
+    }
+
+    return lines_metadata
+
+
+def _prepare_markers_metadata(coords, marker_size, marker_color, marker_only):
+    markers_coordinates = _encode_coordinates(coords, prefix="_marker_")
+    markers_metadata = {
+        'markers_only': marker_only,
+        **markers_coordinates
+    }
+
     if np.ndim(marker_size) > 0:
         marker_size = np.asarray(marker_size)
-        marker_size = marker_size[path_nodes]
-    x, y, z = c.T
-    for coord, cname in [(x, "x"), (y, "y"), (z, "z")]:
-        connectome["_con_{}".format(cname)] = encode(
-            np.asarray(coord, dtype='<f4'))
-    connectome["markers_only"] = False
     if hasattr(marker_size, 'tolist'):
         marker_size = marker_size.tolist()
-    connectome['marker_size'] = marker_size
-    return connectome
+    markers_metadata['marker_size'] = marker_size
+    markers_metadata['marker_color'] = _prepare_colors_for_markers(
+        marker_color,
+        len(coords),
+    )
 
+    return markers_metadata
 
-def _get_markers(coords, colors):
-    connectome = {}
-    coords = np.asarray(coords, dtype='<f4')
-    x, y, z = coords.T
-    for coord, cname in [(x, "x"), (y, "y"), (z, "z")]:
-        connectome["_con_{}".format(cname)] = encode(
-            np.asarray(coord, dtype='<f4'))
-    connectome["marker_color"] = to_color_strings(colors)
-    connectome["markers_only"] = True
-    return connectome
+
+def _get_connectome(adjacency_matrix, coords, threshold=None,
+                    marker_size=None, marker_color='auto', cmap=cm.cold_hot,
+                    symmetric_cmap=True):
+    lines_metadata = _prepare_lines_metadata(
+        adjacency_matrix,
+        coords,
+        threshold,
+        cmap,
+        symmetric_cmap,
+    )
+
+    markers_metadata = _prepare_markers_metadata(
+        coords,
+        marker_size,
+        marker_color,
+        marker_only=False,
+    )
+
+    return {
+        **lines_metadata,
+        **markers_metadata,
+    }
 
 
 def _make_connectome_html(connectome_info, embed_js=True):
@@ -90,8 +226,8 @@ def _make_connectome_html(connectome_info, embed_js=True):
 
 def view_connectome(adjacency_matrix, node_coords, edge_threshold=None,
                     edge_cmap=cm.bwr, symmetric_cmap=True,
-                    linewidth=6., node_size=3., colorbar=True,
-                    colorbar_height=.5, colorbar_fontsize=25,
+                    linewidth=6., node_color='auto', node_size=3.,
+                    colorbar=True, colorbar_height=.5, colorbar_fontsize=25,
                     title=None, title_fontsize=25):
     """Insert a 3d plot of a connectome into an HTML page.
 
@@ -103,6 +239,9 @@ def view_connectome(adjacency_matrix, node_coords, edge_threshold=None,
     node_coords : ndarray, shape=(n_nodes, 3)
         The coordinates of the nodes in MNI space.
 
+    node_color : color or sequence of colors, optional
+        Color(s) of the nodes. Default='auto'.
+
     edge_threshold : str, number or None, optional
         If None, no thresholding.
         If it is a number only connections of amplitude greater
@@ -164,10 +303,13 @@ def view_connectome(adjacency_matrix, node_coords, edge_threshold=None,
         surface.
 
     """
+    node_coords = np.asarray(node_coords)
+
     connectome_info = _get_connectome(
         adjacency_matrix, node_coords,
         threshold=edge_threshold, cmap=edge_cmap,
-        symmetric_cmap=symmetric_cmap, marker_size=node_size)
+        symmetric_cmap=symmetric_cmap, marker_size=node_size,
+        marker_color=node_color)
     connectome_info['line_width'] = linewidth
     connectome_info['colorbar'] = colorbar
     connectome_info['cbar_height'] = colorbar_height
@@ -177,7 +319,7 @@ def view_connectome(adjacency_matrix, node_coords, edge_threshold=None,
     return _make_connectome_html(connectome_info)
 
 
-def view_markers(marker_coords, marker_color=None, marker_size=5.,
+def view_markers(marker_coords, marker_color='auto', marker_size=5.,
                  marker_labels=None, title=None, title_fontsize=25):
     """Insert a 3d plot of markers in a brain into an HTML page.
 
@@ -226,15 +368,18 @@ def view_markers(marker_coords, marker_color=None, marker_size=5.,
         surface.
 
     """
+    marker_coords = np.asarray(marker_coords)
     if marker_color is None:
-        marker_color = ['red' for i in range(len(marker_coords))]
-    connectome_info = _get_markers(marker_coords, marker_color)
-    if hasattr(marker_size, 'tolist'):
-        marker_size = marker_size.tolist()
+        marker_color = ['red' for _ in range(len(marker_coords))]
+    connectome_info = _prepare_markers_metadata(
+        marker_coords,
+        marker_size,
+        marker_color,
+        marker_only=True,
+    )
     if marker_labels is None:
-        marker_labels = ['' for i in range(marker_coords.shape[0])]
-    connectome_info["marker_size"] = marker_size
-    connectome_info["marker_labels"] = marker_labels
+        marker_labels = ['' for _ in range(marker_coords.shape[0])]
+    connectome_info['marker_labels'] = marker_labels
     connectome_info['title'] = title
     connectome_info['title_fontsize'] = title_fontsize
     return _make_connectome_html(connectome_info)


Test patch:
diff --git a/nilearn/plotting/tests/test_html_connectome.py b/nilearn/plotting/tests/test_html_connectome.py
--- a/nilearn/plotting/tests/test_html_connectome.py
+++ b/nilearn/plotting/tests/test_html_connectome.py
@@ -1,9 +1,8 @@
-import warnings
 import json
 
 import numpy as np
+import pytest
 
-from nilearn.plotting import cm
 from nilearn.plotting.js_plotting_utils import decode
 from nilearn.plotting import html_connectome
 
@@ -18,6 +17,21 @@ def test_prepare_line():
     assert(pe == [0, 0, 0, 1, 1, 0, 2, 2, 0, 3, 3, 0]).all()
 
 
+@pytest.mark.parametrize('node_color,expected_marker_colors', [
+    ('cyan', ['#00ffff', '#00ffff', '#00ffff']),
+    ('auto', ['#440154', '#20908c', '#fde724']),
+    (['cyan', 'red', 'blue'], ['#00ffff', '#ff0000', '#0000ff'])
+])
+def test_prepare_colors_for_markers(node_color, expected_marker_colors):
+    number_of_nodes = 3
+    marker_colors = html_connectome._prepare_colors_for_markers(
+        node_color,
+        number_of_nodes,
+    )
+
+    assert marker_colors == expected_marker_colors
+
+
 def _make_connectome():
     adj = np.diag([1.5, .3, 2.5], 2)
     adj += adj.T
@@ -45,14 +59,13 @@ def test_get_connectome():
          40, 20, 0,
          40, 40, 0], dtype='<f4')
     assert (con_x == expected_x).all()
-    assert {'_con_x', '_con_y', '_con_z', '_con_w', 'colorscale'
-            }.issubset(connectome.keys())
-    assert (connectome['cmin'], connectome['cmax']) == (-2.5, 2.5)
+    assert {'_con_x', '_con_y', '_con_z', '_con_w'}.issubset(connectome.keys())
+    assert (connectome['line_cmin'], connectome['line_cmax']) == (-2.5, 2.5)
     adj[adj == 0] = np.nan
     connectome = html_connectome._get_connectome(adj, coord)
     con_x = decode(connectome['_con_x'], '<f4')
     assert (con_x == expected_x).all()
-    assert (connectome['cmin'], connectome['cmax']) == (-2.5, 2.5)
+    assert (connectome['line_cmin'], connectome['line_cmax']) == (-2.5, 2.5)
 
 
 def test_view_connectome():
@@ -71,17 +84,6 @@ def test_view_connectome():
     check_html(html, False, 'connectome-plot')
 
 
-def test_get_markers():
-    coords = np.arange(12).reshape((4, 3))
-    colors = ['r', 'g', 'black', 'white']
-    markers = html_connectome._get_markers(coords, colors)
-    assert markers["marker_color"] == [
-        '#ff0000', '#007f00', '#000000', '#ffffff']
-    assert markers['markers_only']
-    con_x = decode(markers['_con_x'], '<f4')
-    assert np.allclose(con_x, coords[:, 0])
-
-
 def test_view_markers():
     coords = np.arange(12).reshape((4, 3))
     colors = ['r', 'g', 'black', 'white']