Search code examples
pythoncontextmenugraphvizdot

Context menus for nodes and edges for dot graphs using Python


Is there any easy way of adding context menus for nodes and edges for dot graphs using Python? Such that when clicking on a node or an edge, the context menu appears, the user can select a menu entry and then depending on the entry, Python code is executed?


Solution

  • I couldn't find any existing library that allowed arbitrary events for graphviz graphs - the closest I found was xdot which doesn't appear to fulfull your requirements.

    So, I made something that should work for graphviz.Digraph objects. The easiest way to display and register events on a graph I could find was to use JavaScript and export the graph to an SVG. I have used pywebview to run the HTML and JavaScript from Python without relying on a browser.

    Apologies for all the JavaScript in an answer to a Python question, but this solution is intended to work for Python projects and JavaScript seemed to be the only viable approach.

    This solution allows function callbacks to be attatched to both nodes and edges. The edges are quite thin making it hard to click on them, but it is possible, especially near the point of the arrow.

    You can display the graph with the context menus using this code:

    import graphviz
    
    
    # import the code from the other file
    import graphviz_context_menu
    
    # create a simple graph
    dot = graphviz.Digraph(comment='The Round Table', format='svg')
    dot.node('A', 'King Arthur')
    dot.node('B', 'Sir Bedevere the Wise')
    dot.node('L', 'Sir Lancelot the Brave')
    dot.edges(['AB', 'AL'])
    dot.edge('B', 'L', constraint='false')
    
    
    # display the graph
    server = graphviz_context_menu.start_graph_server(
        dot,
        # the context menu for when a node is clicked
        node_menu={
            'Option 1': lambda node: print("option 1,", node, "clicked"),
            'Option 2': lambda node: print("option 2,", node, "clicked"),
        },
        # the context menu for when an edge is clicked
        edge_menu={
            "Edge Context Item": lambda edge: print("edge,", edge, "clicked"),
            "Another Edge Context Item": lambda edge: print("another,", edge, "clicked"),
            "Does nothing": lambda edge: None
        }
    )
    

    This relies upon another file in the same directory called graphviz_context_menu.py with these contents:

    import webview
    import re
    
    js = """
    const svg = document.querySelector("#graph > svg")
    const nodeMenu = document.querySelector("#node_context_menu");
    const edgeMenu = document.querySelector("#edge_context_menu");
    
    const g = svg.childNodes[1];
    let selected;
    
    function addMenu(node, menu) {
        node.addEventListener("contextmenu", e => {
            menu.style.left = `${e.pageX}px`;
            menu.style.top = `${e.pageY}px`;
    
            selected = node.children[0].innerHTML;
    
            setMenuVisible(true, menu);
            e.preventDefault();
            e.stopPropagation();
        });
    }
    
    for(let node of g.childNodes) {
        if(node.tagName === "g"){
            const nodeClass = node.attributes.class.value;
            if(nodeClass === "node"){
                addMenu(node, nodeMenu);
            }
            if(nodeClass === "edge"){
                addMenu(node, edgeMenu);
            }
        }
    }
    
    function setMenuVisible(visible, menu) {
        if(visible) {
            setMenuVisible(false);
        }
        if(menu) {
            menu.style.display = visible ? "block" : "none";
        } else {
            setMenuVisible(visible, nodeMenu);
            setMenuVisible(visible, edgeMenu);
        }
    }
    
    window.addEventListener("click", e => {
        setMenuVisible(false);
    });
    window.addEventListener("contextmenu", e => {
        setMenuVisible(false);
        e.preventDefault();
    });
    
    
    function menuClick(menuType, item) {
        if(menuType === 'edge') {
            selected = selected.replace('>','>');
        }
        pywebview.api.menu_item_clicked(menuType,selected,item);
    }
    """
    
    def make_menu(menu_info, menu_type):
        lis = '\n'.join(
            f'<li class="menu-option" onclick="menuClick(\'{menu_type}\', \'{name}\')">{name}</li>' for name in menu_info)
        return f"""
        <div class="menu" id="{menu_type}_context_menu">
          <ul class="menu-options">
            {lis}
          </ul>
        </div>
        """
    
    
    style = """
    .menu {
        box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2);
        position: absolute;
        display: none;
        background-color: white;
    }
    .menu-options {
        list-style: none;
        padding: 10px 0;
    }
    .menu-option {
        font-weight: 500;
        font-size: 14px;
        padding: 10px 40px 10px 20px;
        cursor: pointer;
        white-space: nowrap;
    }
    .menu-option:hover {
        background: rgba(0, 0, 0, 0.2);
    }
    """
    
    
    
    def start_graph_server(graph, node_menu, edge_menu):
        svg = graph.pipe().decode()
    
        match = re.search(r'<svg width="(\d+)pt" height="(\d+)pt"', svg)
        width, height = match[1], match[2]
    
        html = f"""<!DOCTYPE html>
                    <html>
                    <head>
                        <meta charset="utf-8"/>
                        <style>
                            {style}
                        </style>
                    </head>
                    <body>
                        <div id="graph">{svg}</div>
                        {make_menu(node_menu, 'node')}
                        {make_menu(edge_menu, 'edge')}
                        <script>{js}</script>
                    </body>
                    </html>
                    """
    
        class Api:
            @staticmethod
            def menu_item_clicked(menu_type, selected, item):
                if menu_type == "node":
                    callback = node_menu[item]
                    callback(selected)
                elif menu_type == "edge":
                    callback = edge_menu[item]
                    callback(selected)
                return {}
    
        window = webview.create_window(
            "Graph Viewer",
            html=html,
            width=int(width) / 0.75 + 400, height=int(height) / 0.75 + 400,
            js_api=Api()
        )
        webview.start(args=window)