GraphWidget takes nodes and edges from Python — plain strings, numbers or dicts —
and lays them out as a force-directed graph. Browser-side positions survive changes to
nodes and edges, so growing a graph from Python does not reshuffle the whole
picture, and clicks come back on selected_nodes and selected_edges for the rest of
the notebook to read.
See also: EdgeDraw for drawing the edges by hand instead of passing
them in, Neo4jWidget for querying a live Neo4j database, and
WidgetDAG for arranging live widgets as a DAG.
Bases: AnyWidget
Programmatic force-directed graph widget.
GraphWidget renders nodes and edges supplied from Python. Nodes may be
strings, numbers, or dicts. Edges may be (source, target) pairs or dicts
with source and target keys.
width=None (the default) makes the widget fill its container's width
and reflow when the container resizes. Passing an integer width pins
the SVG to that exact pixel size. height is always an exact pixel
height (default 400).
defadd_edge(self,source:Any,target:Any,*,id:Any=None,name:Any=None,width:int|float|None=None,color:str|None=None,data:Any=None,**attrs:Any,)->str:"""Add an edge and return its normalized id."""edge={"source":source,"target":target,**attrs}ifidisnotNone:edge["id"]=idifnameisnotNone:edge["name"]=nameifwidthisnotNone:edge["width"]=widthifcolorisnotNone:edge["color"]=colorifdataisnotNone:edge["data"]=datanew_edges=self._coerce_edges([*self.edges,edge],self.nodes)self.edges=new_edgesreturnnew_edges[-1]["id"]
defadd_node(self,name:Any=None,*,id:Any=None,size:int|float|None=None,color:str|None=None,data:Any=None,**attrs:Any,)->str:"""Add a node and return its normalized id."""node=dict(attrs)ifidisnotNone:node["id"]=idifnameisnotNone:node["name"]=nameifsizeisnotNone:node["size"]=sizeifcolorisnotNone:node["color"]=colorifdataisnotNone:node["data"]=datanew_nodes=self._coerce_nodes([*self.nodes,node])self.nodes=new_nodesreturnnew_nodes[-1]["id"]
defattach_node(self,source:Any,name:Any=None,*,id:Any=None,edge_id:Any=None,edge_name:Any=None,size:int|float|None=None,color:str|None=None,data:Any=None,edge_width:int|float|None=None,edge_color:str|None=None,edge_data:Any=None,**attrs:Any,)->tuple[str,str]:"""Attach a node to an existing source node. If ``id`` or ``name`` resolves to an existing node, only the edge is added. Otherwise a new node is created first. Returns: The normalized ``(node_id, edge_id)`` pair. """source_id=self._resolve_endpoint(source,self.nodes,self._node_lookup(self.nodes))lookup=self._node_lookup(self.nodes)node=dict(attrs)ifidisnotNone:node["id"]=idifnameisnotNone:node["name"]=nameifsizeisnotNone:node["size"]=sizeifcolorisnotNone:node["color"]=colorifdataisnotNone:node["data"]=datanode_id=Noneforendpointin(id,name):ifendpointisNone:continuetry:node_id=self._resolve_endpoint(endpoint,self.nodes,lookup)breakexceptValueError:passifnode_idisNone:new_nodes=self._coerce_nodes([*self.nodes,node])node_id=new_nodes[-1]["id"]else:updates=dict(attrs)ifnameisnotNoneandidisnotNone:updates["name"]=nameifsizeisnotNone:updates["size"]=sizeifcolorisnotNone:updates["color"]=colorifdataisnotNone:updates["data"]=datanew_nodes=[{**existing,**updates}ifexisting["id"]==node_idelseexistingforexistinginself.nodes]edge:dict[str,Any]={"source":source_id,"target":node_id}ifedge_idisnotNone:edge["id"]=edge_idifedge_nameisnotNone:edge["name"]=edge_nameifedge_widthisnotNone:edge["width"]=edge_widthifedge_colorisnotNone:edge["color"]=edge_colorifedge_dataisnotNone:edge["data"]=edge_datanew_edges=self._coerce_edges([*self.edges,edge],new_nodes)edge_id=new_edges[-1]["id"]withself.hold_sync():self.nodes=new_nodesself.edges=new_edgesreturnnode_id,edge_id
defdetach_node(self,node:Any,*,delete:bool=False)->None:"""Remove all edges attached to a node. Set ``delete=True`` to remove the node as well. """node_id=self._resolve_endpoint(node,self.nodes,self._node_lookup(self.nodes))new_nodes=[nforninself.nodesifnotdeleteorn["id"]!=node_id]new_edges=[eforeinself.edgesife["source"]!=node_idande["target"]!=node_id]remaining_edges={edge["id"]foredgeinnew_edges}withself.hold_sync():self.nodes=new_nodesself.edges=new_edgesifdelete:self.selected_nodes=[nforninself.selected_nodesifn!=node_id]self.selected_edges=[eforeinself.selected_edgesifeinremaining_edges]
defget_adjacency_matrix(self,directed:bool|None=None):"""Return an adjacency matrix for the current graph."""importnumpyasnpifdirectedisNone:directed=self.directednode_ids=[node["id"]fornodeinself.nodes]index={node_id:ifori,node_idinenumerate(node_ids)}matrix=np.zeros((len(node_ids),len(node_ids)))foredgeinself.edges:ifedge["source"]notinindexoredge["target"]notinindex:continuesrc=index[edge["source"]]dst=index[edge["target"]]matrix[src][dst]=1ifnotdirected:matrix[dst][src]=1returnmatrix
defget_selected_edge_data(self)->list[dict]:"""Return full edge dicts for currently selected edges."""selected=set(self.selected_edges)return[edgeforedgeinself.edgesifedge["id"]inselected]
defget_selected_node_data(self)->list[dict]:"""Return full node dicts for currently selected nodes."""selected=set(self.selected_nodes)return[nodefornodeinself.nodesifnode["id"]inselected]
defremove_edge(self,edge:Any)->None:"""Remove an edge by id or index."""ifisinstance(edge,int)and0<=edge<len(self.edges):edge_id=self.edges[edge]["id"]else:edge_id=self._stringify(edge)self.edges=[eforeinself.edgesife["id"]!=edge_id]self.selected_edges=[eforeinself.selected_edgesife!=edge_id]
defremove_node(self,node:Any)->None:"""Remove a node by id, unique name, or index, including incident edges."""node_id=self._resolve_endpoint(node,self.nodes,self._node_lookup(self.nodes))self.detach_node(node_id,delete=True)
GraphWidget preserves browser-side node positions when nodes or edges
change. Newly connected nodes are initialized near the existing endpoint they
attach to. Use attach_node(source, name, ...) when adding one new node plus
its connecting edge from Python; if name or id already resolves to a node,
only the edge is added. Use detach_node(node) to remove all edges attached to
a node while keeping the node visible, or detach_node(node, delete=True) to
remove the node too.