How to apply "dodge" to lines using from_networkx

What are you trying to do?

I’m build a general-purpose network plot library, creating NX graph from a DataFrame, and applying additional node and edge attribs from other columns.
The problem I have is that Multiline glyphs start and end coords run from the center of the node circles. This messes up the visuals a bit but especially the hover and selection (e.g. if you have a node with many edges it’s difficult to find the node “hover spot”). I’d like to be able to draw the lines so that their
x,y coords end would finish at the edge of the node glyphs.
I’ve used dodge in other projects to apply a static offset to things, so I’m thinking that this may be applicable here.
I’m manually creating/modifying the node and edge renderer properties of the the graph_render to control things like selection coloring and having separate hover_glyph for nodes and edges.

Is there a way to apply dodge to the data when setting the edge_renderer.glyph?

Here is a minimal bit of the code:

    plot = figure(
        title=title,
        x_range=(-3, 3),
        y_range=(-3, 3),
        width=width,
        height=height,
    )

    graph_renderer = from_networkx(
        nx_graph, nx.spring_layout, scale=scale, center=(0, 0)
    )
    _create_edge_renderer(graph_renderer, edge_color=kwargs.pop("edge_color", "black"))
    _create_node_renderer(graph_renderer, node_size, "node_color")
   plot.renderers.append(graph_renderer)

    ...
def _create_edge_renderer(graph_renderer: Renderer, edge_color: str):
    """Create graph render for edges."""
    graph_renderer.edge_renderer.hover_glyph = MultiLine(
        line_color=Spectral4[1], line_width=5
    )
    graph_renderer.edge_renderer.glyph = MultiLine(
        line_alpha=0.8, line_color=edge_color, line_width=1
    )
    graph_renderer.edge_renderer.selection_glyph = MultiLine(
        line_color=Spectral4[2], line_width=5
    )

Have you tried the built in dodge or jitter transforms?

bar_dodged.py — Bokeh 2.4.3 Documentation

categorical_scatter_jitter.py — Bokeh 2.4.3 Documentation

Hi @crashMOGWAI - yes I’m familiar with the dodge transform and I think that is the solution.
My question is more how to use in this case. There are two grey areas for me:

  1. the from_networkx function creates a derived ColumnDataSource (I think) with start and end columns. This gets assigned to the edge_renderer.
  2. MultiLine glyph doesn’t take simple x, y coords but a xs, ys, which I think are both lists of coords.

Normally with dodge, you’d feed it the scalar col name and an offset and assign it to the glyph x parameter.

Here, what I want to do is shorten the lines by the radius of the nodes at each end. Ideally, I’d like to do this with a simple transform like dodge - ideally avoiding too much geometry or JS code! :slight_smile:

However, if you could point me to examples of custom transforms that do something like this and how to use a custom transform in this context I would be very grateful.

Basic question because I’m unfamiliar with using the network graphs: Is the node component rendered using a Scatter glyph (given a specific “marker size” arg) or a Circle (given a specific “radius” arg) glyph?

If the latter, doing the math on trimming each line should be pretty straightforward… Can you provide an MRE I can play around with?

It’s using Circle.
The digging I’ve done suggests that I could use a CustomJSTransform to do what you say but, not only am I not super comfortable with JS, also I’m not sure what the layout of the xs, ys parameters are (is this like [[start1_x, end1_x], [start2_x, end2_x]…]?)

I’ve included the whole code of the module here.

"""Module for common display functions."""
from typing import List, Optional, Union

import networkx as nx
from bokeh.io import output_notebook
from bokeh.models import (
    BoxSelectTool,
    Circle,
    EdgesAndLinkedNodes,
    HoverTool,
    Label,
    MultiLine,
    NodesAndLinkedEdges,
    Renderer,
    TapTool,
)
from bokeh.palettes import Spectral4
from bokeh.plotting import figure, from_networkx, show

from .._version import VERSION

__version__ = VERSION
__author__ = "Ian Hellen"


# pylint: disable=too-many-arguments, too-many-locals
def plot_nx_graph(
    nx_graph: nx.Graph,
    title: str = "Data Graph",
    node_size: int = 25,
    font_size: Union[int, str] = 10,
    height: int = 800,
    width: int = 800,
    scale: int = 2,
    hide: bool = False,
    source_attrs: Optional[List[str]] = None,
    target_attrs: Optional[List[str]] = None,
    edge_attrs: Optional[List[str]] = None,
    **kwargs,
) -> figure:
    """
    Plot entity graph with Bokeh.

    Parameters
    ----------
    nx_graph : nx.Graph
        The entity graph as a networkX graph
    title : str
        Title for the plot, by default 'Data Graph'
    node_size : int, optional
        Size of the nodes in pixels, by default 25
    font_size : int, optional
        Font size for node labels, by default 10
        Can be an integer (point size) or a string (e.g. "10pt")
    width : int, optional
        Width in pixels, by default 800
    height : int, optional
        Image height (the default is 800)
    scale : int, optional
        Position scale (the default is 2)
    hide : bool, optional
        Don't show the plot, by default False. If True, just
        return the figure.
    source_attrs : Optional[List[str]], optional
        Optional list of source attributes to use as hover properties, by default None
    target_attrs : Optional[List[str]], optional
        Optional list of target attributes to use as hover properties, by default None
    edge_attrs : Optional[List[str]], optional
        Optional list of edge attributes to use as hover properties, by default None

    Other Parameters
    ----------------
    source_color : str, optional
        The color of the source nodes, by default 'light-blue'
    target_color : str, optional
        The color of the source nodes, by default 'light-green'
    edge_color : str, optional
        The color of the edges, by default 'black'

    Returns
    -------
    bokeh.plotting.figure
        The network plot.

    """
    output_notebook()
    font_pnt = f"{font_size}pt" if isinstance(font_size, int) else font_size
    node_attrs = {
        node: attrs.get(
            "color",
            kwargs.pop("source_color", "lightblue")
            if attrs.get("node_role", "source") == "source"
            else kwargs.pop("target_color", "lightgreen"),
        )
        for node, attrs in nx_graph.nodes(data=True)
    }
    nx.set_node_attributes(nx_graph, node_attrs, "node_color")

    plot = figure(
        title=title,
        x_range=(-3, 3),
        y_range=(-3, 3),
        width=width,
        height=height,
    )

    graph_renderer = from_networkx(
        nx_graph, nx.spring_layout, scale=scale, center=(0, 0)
    )
    _create_edge_renderer(graph_renderer, edge_color=kwargs.pop("edge_color", "black"))
    _create_node_renderer(graph_renderer, node_size, "node_color")

    graph_renderer.selection_policy = NodesAndLinkedEdges()
    graph_renderer.inspection_policy = EdgesAndLinkedNodes()
    plot.renderers.append(graph_renderer)

    hover_tools = [
        _create_node_hover(source_attrs, target_attrs, [graph_renderer.node_renderer])
    ]
    if edge_attrs:
        hover_tools.append(
            _create_edge_hover(edge_attrs, [graph_renderer.edge_renderer])
        )
    plot.add_tools(*hover_tools, TapTool(), BoxSelectTool())

    # Create labels
    for name, pos in graph_renderer.layout_provider.graph_layout.items():
        label = Label(
            x=pos[0],
            y=pos[1],
            x_offset=5,
            y_offset=5,
            text=name,
            text_font_size=font_pnt,
        )
        plot.add_layout(label)
    if not hide:
        show(plot)
    return plot


def _create_node_hover(
    source_attrs: Optional[List[str]],
    target_attrs: Optional[List[str]],
    renderers: List[Renderer],
) -> HoverTool:
    """Create a hover tool for nodes."""
    node_attr_cols = set((list(source_attrs or [])) + (list(target_attrs or [])))
    node_tooltips = [
        ("node_type", "@node_type"),
        *[(col, f"@{{{col}}}") for col in node_attr_cols],
    ]
    return HoverTool(tooltips=node_tooltips, renderers=renderers)


def _create_edge_hover(edge_attrs: List[str], renderers: List[Renderer]) -> HoverTool:
    """Create a hover tool for nodes."""
    edge_attr_cols = edge_attrs or []
    edge_tooltips = [
        *[(col, f"@{{{col}}}") for col in edge_attr_cols],
    ]
    return HoverTool(tooltips=edge_tooltips, renderers=renderers)


def _create_node_renderer(graph_renderer: Renderer, node_size: int, fill_color: str):
    """Create graph render for nodes."""
    graph_renderer.node_renderer.glyph = Circle(size=node_size, fill_color=fill_color)
    graph_renderer.node_renderer.hover_glyph = Circle(
        size=node_size, fill_color=Spectral4[1]
    )
    graph_renderer.node_renderer.selection_glyph = Circle(
        size=node_size, fill_color=Spectral4[2]
    )


def _create_edge_renderer(graph_renderer: Renderer, edge_color: str):
    """Create graph render for edges."""
    graph_renderer.edge_renderer.hover_glyph = MultiLine(
        line_color=Spectral4[1], line_width=5
    )
    graph_renderer.edge_renderer.glyph = MultiLine(
        line_alpha=0.8, line_color=edge_color, line_width=1
    )
    graph_renderer.edge_renderer.selection_glyph = MultiLine(
        line_color=Spectral4[2], line_width=5
    )

I’m about to be OoF for a day (son’s graduation) but I can give you some kind of serialized networkx data to feed to the from_networkx function.
Any help would be dearly appreciated. :smiley:

Do all the nodes have the same/fixed radius?

It’s controlled by the node_size parameter. For the moment all nodes will have the same radius.
Even if these did change, having an “exclusion zone” around the center of each node (even if it didn’t extend to the edge of the circle would be fine I think - so you can treat as governed by a single param

Anyone have thoughts on what a suitable custom transform would look like?
And how to apply it to the MultiLine glyph.
The math (given a fixed radius) is going to be a bit of geometry using xs and ys to calculate the relative shifting of each coordinate - which I’m just about up to I think.

The problem (as I see it) with a transform is that it would be applying separately to the xs and ys parameters of MultiLine - i.e. each custom dodge transform would be receiving only the list of X coords or the list of Y coords. To do the correct calculations, you’d need both the x and y coordinates of the line to calculate the required shift of the line ends.